diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:08:58 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:08:58 +0900 |
| commit | c086c8b35b6c6f0b0e2623e9b6421713a540941a (patch) | |
| tree | aa6937e799f88d497ce6bcae5bb347a945c77d27 | |
| parent | d91888da7199cdde7662910debfffaa758b8a128 (diff) | |
| download | kioku-c086c8b35b6c6f0b0e2623e9b6421713a540941a.tar.gz kioku-c086c8b35b6c6f0b0e2623e9b6421713a540941a.tar.zst kioku-c086c8b35b6c6f0b0e2623e9b6421713a540941a.zip | |
feat(client): add local CRUD repositories for IndexedDB
Add localDeckRepository, localCardRepository, and localReviewLogRepository
with full CRUD operations for offline support. Includes sync tracking
with _synced flag and methods for finding unsynced items.
🤖 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 | 6 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 9 | ||||
| -rw-r--r-- | src/client/db/repositories.test.ts | 581 | ||||
| -rw-r--r-- | src/client/db/repositories.ts | 379 |
5 files changed, 973 insertions, 3 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index ee7ec63..e98d72f 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -148,9 +148,9 @@ Smaller features first to enable early MVP validation. ### IndexedDB (Local Storage) - [x] Dexie.js setup -- [ ] Local schema (with _synced flag) -- [ ] Local CRUD operations for Deck/Card/ReviewLog -- [ ] Add tests +- [x] Local schema (with _synced flag) +- [x] Local CRUD operations for Deck/Card/ReviewLog +- [x] Add tests ### Sync Engine - [ ] POST /api/sync/push endpoint diff --git a/package.json b/package.json index 727f63d..5f4a362 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "react": "^19.2.1", "react-dom": "^19.2.1", "ts-fsrs": "^5.2.3", + "uuid": "^13.0.0", "wouter": "^3.8.1", "zod": "^4.1.13" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c98ada1..290fdbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: ts-fsrs: specifier: ^5.2.3 version: 5.2.3 + uuid: + specifier: ^13.0.0 + version: 13.0.0 wouter: specifier: ^3.8.1 version: 3.8.1(react@19.2.1) @@ -2754,6 +2757,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + vite-plugin-pwa@1.2.0: resolution: {integrity: sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==} engines: {node: '>=16.0.0'} @@ -5622,6 +5629,8 @@ snapshots: dependencies: react: 19.2.1 + uuid@13.0.0: {} + vite-plugin-pwa@1.2.0(vite@7.2.6(@types/node@24.10.1)(terser@5.44.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): dependencies: debug: 4.4.3 diff --git a/src/client/db/repositories.test.ts b/src/client/db/repositories.test.ts new file mode 100644 index 0000000..0121541 --- /dev/null +++ b/src/client/db/repositories.test.ts @@ -0,0 +1,581 @@ +/** + * @vitest-environment jsdom + */ +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CardState, db, Rating } from "./index"; +import { + localCardRepository, + localDeckRepository, + localReviewLogRepository, +} from "./repositories"; + +describe("localDeckRepository", () => { + beforeEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + }); + + afterEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + }); + + describe("create", () => { + it("should create a deck with generated id and timestamps", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: "A test deck", + newCardsPerDay: 20, + }); + + expect(deck.id).toBeDefined(); + expect(deck.userId).toBe("user-1"); + expect(deck.name).toBe("Test Deck"); + expect(deck.description).toBe("A test deck"); + expect(deck.newCardsPerDay).toBe(20); + expect(deck.createdAt).toBeInstanceOf(Date); + expect(deck.updatedAt).toBeInstanceOf(Date); + expect(deck.deletedAt).toBeNull(); + expect(deck.syncVersion).toBe(0); + expect(deck._synced).toBe(false); + }); + + it("should persist the deck to the database", async () => { + const created = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 10, + }); + + const found = await db.decks.get(created.id); + expect(found).toEqual(created); + }); + }); + + describe("findById", () => { + it("should return the deck if found", async () => { + const created = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const found = await localDeckRepository.findById(created.id); + expect(found).toEqual(created); + }); + + it("should return undefined if not found", async () => { + const found = await localDeckRepository.findById("non-existent"); + expect(found).toBeUndefined(); + }); + }); + + describe("findByUserId", () => { + it("should return all decks for a user", async () => { + await localDeckRepository.create({ + userId: "user-1", + name: "Deck 1", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.create({ + userId: "user-1", + name: "Deck 2", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.create({ + userId: "user-2", + name: "Other User Deck", + description: null, + newCardsPerDay: 20, + }); + + const decks = await localDeckRepository.findByUserId("user-1"); + expect(decks).toHaveLength(2); + expect(decks.map((d) => d.name).sort()).toEqual(["Deck 1", "Deck 2"]); + }); + + it("should exclude soft-deleted decks", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Deleted Deck", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.delete(deck.id); + + const decks = await localDeckRepository.findByUserId("user-1"); + expect(decks).toHaveLength(0); + }); + }); + + describe("update", () => { + it("should update deck fields", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Original Name", + description: null, + newCardsPerDay: 20, + }); + + const updated = await localDeckRepository.update(deck.id, { + name: "Updated Name", + description: "New description", + }); + + expect(updated?.name).toBe("Updated Name"); + expect(updated?.description).toBe("New description"); + expect(updated?._synced).toBe(false); + expect(updated?.updatedAt.getTime()).toBeGreaterThan( + deck.updatedAt.getTime(), + ); + }); + + it("should return undefined for non-existent deck", async () => { + const updated = await localDeckRepository.update("non-existent", { + name: "New Name", + }); + expect(updated).toBeUndefined(); + }); + }); + + describe("delete", () => { + it("should soft delete a deck", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const result = await localDeckRepository.delete(deck.id); + expect(result).toBe(true); + + const found = await localDeckRepository.findById(deck.id); + expect(found?.deletedAt).not.toBeNull(); + expect(found?._synced).toBe(false); + }); + + it("should return false for non-existent deck", async () => { + const result = await localDeckRepository.delete("non-existent"); + expect(result).toBe(false); + }); + }); + + describe("findUnsynced", () => { + it("should return unsynced decks", async () => { + const deck1 = await localDeckRepository.create({ + userId: "user-1", + name: "Unsynced", + description: null, + newCardsPerDay: 20, + }); + const deck2 = await localDeckRepository.create({ + userId: "user-1", + name: "Synced", + description: null, + newCardsPerDay: 20, + }); + await localDeckRepository.markSynced(deck2.id, 1); + + const unsynced = await localDeckRepository.findUnsynced(); + expect(unsynced).toHaveLength(1); + expect(unsynced[0]?.id).toBe(deck1.id); + }); + }); + + describe("markSynced", () => { + it("should mark a deck as synced with version", async () => { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test", + description: null, + newCardsPerDay: 20, + }); + + await localDeckRepository.markSynced(deck.id, 5); + + const found = await localDeckRepository.findById(deck.id); + expect(found?._synced).toBe(true); + expect(found?.syncVersion).toBe(5); + }); + }); +}); + +describe("localCardRepository", () => { + let deckId: string; + + beforeEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + deckId = deck.id; + }); + + afterEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + }); + + describe("create", () => { + it("should create a card with FSRS defaults", async () => { + const card = await localCardRepository.create({ + deckId, + front: "Question", + back: "Answer", + }); + + expect(card.id).toBeDefined(); + expect(card.deckId).toBe(deckId); + expect(card.front).toBe("Question"); + expect(card.back).toBe("Answer"); + expect(card.state).toBe(CardState.New); + expect(card.stability).toBe(0); + expect(card.difficulty).toBe(0); + expect(card.reps).toBe(0); + expect(card.lapses).toBe(0); + expect(card.lastReview).toBeNull(); + expect(card._synced).toBe(false); + }); + }); + + describe("findByDeckId", () => { + it("should return all cards for a deck", async () => { + await localCardRepository.create({ deckId, front: "Q1", back: "A1" }); + await localCardRepository.create({ deckId, front: "Q2", back: "A2" }); + + const cards = await localCardRepository.findByDeckId(deckId); + expect(cards).toHaveLength(2); + }); + + it("should exclude soft-deleted cards", async () => { + const card = await localCardRepository.create({ + deckId, + front: "Q", + back: "A", + }); + await localCardRepository.delete(card.id); + + const cards = await localCardRepository.findByDeckId(deckId); + expect(cards).toHaveLength(0); + }); + }); + + describe("findDueCards", () => { + it("should return cards that are due", async () => { + const pastDue = new Date(Date.now() - 60000); + const future = new Date(Date.now() + 60000); + + const card1 = await localCardRepository.create({ + deckId, + front: "Due", + back: "A", + }); + await db.cards.update(card1.id, { due: pastDue }); + + const card2 = await localCardRepository.create({ + deckId, + front: "Not Due", + back: "B", + }); + await db.cards.update(card2.id, { due: future }); + + const dueCards = await localCardRepository.findDueCards(deckId); + expect(dueCards).toHaveLength(1); + expect(dueCards[0]?.front).toBe("Due"); + }); + + it("should respect limit parameter", async () => { + for (let i = 0; i < 5; i++) { + await localCardRepository.create({ + deckId, + front: `Q${i}`, + back: `A${i}`, + }); + } + + const dueCards = await localCardRepository.findDueCards(deckId, 3); + expect(dueCards).toHaveLength(3); + }); + }); + + describe("findNewCards", () => { + it("should return only new cards", async () => { + await localCardRepository.create({ + deckId, + front: "New", + back: "A", + }); + + const reviewedCard = await localCardRepository.create({ + deckId, + front: "Reviewed", + back: "B", + }); + await db.cards.update(reviewedCard.id, { state: CardState.Review }); + + const newCards = await localCardRepository.findNewCards(deckId); + expect(newCards).toHaveLength(1); + expect(newCards[0]?.front).toBe("New"); + }); + }); + + describe("update", () => { + it("should update card content", async () => { + const card = await localCardRepository.create({ + deckId, + front: "Original", + back: "Original", + }); + + const updated = await localCardRepository.update(card.id, { + front: "Updated Front", + back: "Updated Back", + }); + + expect(updated?.front).toBe("Updated Front"); + expect(updated?.back).toBe("Updated Back"); + expect(updated?._synced).toBe(false); + }); + }); + + describe("updateScheduling", () => { + it("should update FSRS scheduling data", async () => { + const card = await localCardRepository.create({ + deckId, + front: "Q", + back: "A", + }); + + const now = new Date(); + const updated = await localCardRepository.updateScheduling(card.id, { + state: CardState.Review, + due: now, + stability: 10.5, + difficulty: 5.2, + elapsedDays: 1, + scheduledDays: 5, + reps: 1, + lapses: 0, + lastReview: now, + }); + + expect(updated?.state).toBe(CardState.Review); + expect(updated?.stability).toBe(10.5); + expect(updated?.difficulty).toBe(5.2); + expect(updated?.reps).toBe(1); + expect(updated?._synced).toBe(false); + }); + }); + + describe("delete", () => { + it("should soft delete a card", async () => { + const card = await localCardRepository.create({ + deckId, + front: "Q", + back: "A", + }); + + const result = await localCardRepository.delete(card.id); + expect(result).toBe(true); + + const found = await localCardRepository.findById(card.id); + expect(found?.deletedAt).not.toBeNull(); + }); + }); +}); + +describe("localReviewLogRepository", () => { + let deckId: string; + let cardId: string; + + beforeEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + deckId = deck.id; + + const card = await localCardRepository.create({ + deckId, + front: "Q", + back: "A", + }); + cardId = card.id; + }); + + afterEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + }); + + describe("create", () => { + it("should create a review log", async () => { + const now = new Date(); + const reviewLog = await localReviewLogRepository.create({ + cardId, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: now, + durationMs: 5000, + }); + + expect(reviewLog.id).toBeDefined(); + expect(reviewLog.cardId).toBe(cardId); + expect(reviewLog.rating).toBe(Rating.Good); + expect(reviewLog._synced).toBe(false); + }); + }); + + describe("findByCardId", () => { + it("should return all review logs for a card", async () => { + await localReviewLogRepository.create({ + cardId, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 5000, + }); + await localReviewLogRepository.create({ + cardId, + userId: "user-1", + rating: Rating.Easy, + state: CardState.Learning, + scheduledDays: 3, + elapsedDays: 1, + reviewedAt: new Date(), + durationMs: 3000, + }); + + const logs = await localReviewLogRepository.findByCardId(cardId); + expect(logs).toHaveLength(2); + }); + }); + + describe("findByUserId", () => { + it("should return all review logs for a user", async () => { + await localReviewLogRepository.create({ + cardId, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 5000, + }); + await localReviewLogRepository.create({ + cardId, + userId: "user-2", + rating: Rating.Hard, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 4000, + }); + + const logs = await localReviewLogRepository.findByUserId("user-1"); + expect(logs).toHaveLength(1); + expect(logs[0]?.rating).toBe(Rating.Good); + }); + }); + + describe("findUnsynced", () => { + it("should return unsynced review logs", async () => { + const log1 = await localReviewLogRepository.create({ + cardId, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date(), + durationMs: 5000, + }); + const log2 = await localReviewLogRepository.create({ + cardId, + userId: "user-1", + rating: Rating.Easy, + state: CardState.Learning, + scheduledDays: 3, + elapsedDays: 1, + reviewedAt: new Date(), + durationMs: 3000, + }); + await localReviewLogRepository.markSynced(log2.id, 1); + + const unsynced = await localReviewLogRepository.findUnsynced(); + expect(unsynced).toHaveLength(1); + expect(unsynced[0]?.id).toBe(log1.id); + }); + }); + + describe("findByDateRange", () => { + it("should return review logs within date range", async () => { + const yesterday = new Date(Date.now() - 86400000); + const today = new Date(); + const tomorrow = new Date(Date.now() + 86400000); + + await localReviewLogRepository.create({ + cardId, + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: yesterday, + durationMs: 5000, + }); + await localReviewLogRepository.create({ + cardId, + userId: "user-1", + rating: Rating.Easy, + state: CardState.Learning, + scheduledDays: 3, + elapsedDays: 1, + reviewedAt: today, + durationMs: 3000, + }); + + const startOfToday = new Date(today); + startOfToday.setHours(0, 0, 0, 0); + + const logs = await localReviewLogRepository.findByDateRange( + "user-1", + startOfToday, + tomorrow, + ); + expect(logs).toHaveLength(1); + expect(logs[0]?.rating).toBe(Rating.Easy); + }); + }); +}); diff --git a/src/client/db/repositories.ts b/src/client/db/repositories.ts new file mode 100644 index 0000000..73abb05 --- /dev/null +++ b/src/client/db/repositories.ts @@ -0,0 +1,379 @@ +import { v4 as uuidv4 } from "uuid"; +import { + CardState, + type CardStateType, + db, + type LocalCard, + type LocalDeck, + type LocalReviewLog, + type RatingType, +} from "./index"; + +/** + * Local deck repository for IndexedDB operations + */ +export const localDeckRepository = { + /** + * Get all decks for a user (excluding soft-deleted) + */ + async findByUserId(userId: string): Promise<LocalDeck[]> { + return db.decks + .where("userId") + .equals(userId) + .filter((deck) => deck.deletedAt === null) + .toArray(); + }, + + /** + * Get a deck by ID + */ + async findById(id: string): Promise<LocalDeck | undefined> { + return db.decks.get(id); + }, + + /** + * Create a new deck + */ + async create( + data: Omit< + LocalDeck, + "id" | "createdAt" | "updatedAt" | "deletedAt" | "syncVersion" | "_synced" + >, + ): Promise<LocalDeck> { + const now = new Date(); + const deck: LocalDeck = { + id: uuidv4(), + ...data, + createdAt: now, + updatedAt: now, + deletedAt: null, + syncVersion: 0, + _synced: false, + }; + await db.decks.add(deck); + return deck; + }, + + /** + * Update a deck + */ + async update( + id: string, + data: Partial<Pick<LocalDeck, "name" | "description" | "newCardsPerDay">>, + ): Promise<LocalDeck | undefined> { + const deck = await db.decks.get(id); + if (!deck) return undefined; + + const updatedDeck: LocalDeck = { + ...deck, + ...data, + updatedAt: new Date(), + _synced: false, + }; + await db.decks.put(updatedDeck); + return updatedDeck; + }, + + /** + * Soft delete a deck + */ + async delete(id: string): Promise<boolean> { + const deck = await db.decks.get(id); + if (!deck) return false; + + await db.decks.update(id, { + deletedAt: new Date(), + updatedAt: new Date(), + _synced: false, + }); + return true; + }, + + /** + * Get all unsynced decks + */ + async findUnsynced(): Promise<LocalDeck[]> { + return db.decks.filter((deck) => !deck._synced).toArray(); + }, + + /** + * Mark a deck as synced + */ + async markSynced(id: string, syncVersion: number): Promise<void> { + await db.decks.update(id, { _synced: true, syncVersion }); + }, + + /** + * Upsert a deck from server (for sync pull) + */ + async upsertFromServer(deck: LocalDeck): Promise<void> { + await db.decks.put({ ...deck, _synced: true }); + }, +}; + +/** + * Local card repository for IndexedDB operations + */ +export const localCardRepository = { + /** + * Get all cards for a deck (excluding soft-deleted) + */ + async findByDeckId(deckId: string): Promise<LocalCard[]> { + return db.cards + .where("deckId") + .equals(deckId) + .filter((card) => card.deletedAt === null) + .toArray(); + }, + + /** + * Get a card by ID + */ + async findById(id: string): Promise<LocalCard | undefined> { + return db.cards.get(id); + }, + + /** + * Get due cards for a deck + */ + async findDueCards(deckId: string, limit?: number): Promise<LocalCard[]> { + const now = new Date(); + const query = db.cards + .where("deckId") + .equals(deckId) + .filter((card) => card.deletedAt === null && card.due <= now); + + const cards = await query.toArray(); + // Sort by due date ascending + cards.sort((a, b) => a.due.getTime() - b.due.getTime()); + + return limit ? cards.slice(0, limit) : cards; + }, + + /** + * Get new cards for a deck (cards that haven't been reviewed yet) + */ + async findNewCards(deckId: string, limit?: number): Promise<LocalCard[]> { + const cards = await db.cards + .where("deckId") + .equals(deckId) + .filter((card) => card.deletedAt === null && card.state === CardState.New) + .toArray(); + + // Sort by creation date ascending + cards.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + + return limit ? cards.slice(0, limit) : cards; + }, + + /** + * Create a new card + */ + async create( + data: Omit< + LocalCard, + | "id" + | "state" + | "due" + | "stability" + | "difficulty" + | "elapsedDays" + | "scheduledDays" + | "reps" + | "lapses" + | "lastReview" + | "createdAt" + | "updatedAt" + | "deletedAt" + | "syncVersion" + | "_synced" + >, + ): Promise<LocalCard> { + const now = new Date(); + const card: LocalCard = { + id: uuidv4(), + ...data, + state: CardState.New, + due: now, + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: now, + updatedAt: now, + deletedAt: null, + syncVersion: 0, + _synced: false, + }; + await db.cards.add(card); + return card; + }, + + /** + * Update a card's content + */ + async update( + id: string, + data: Partial<Pick<LocalCard, "front" | "back">>, + ): Promise<LocalCard | undefined> { + const card = await db.cards.get(id); + if (!card) return undefined; + + const updatedCard: LocalCard = { + ...card, + ...data, + updatedAt: new Date(), + _synced: false, + }; + await db.cards.put(updatedCard); + return updatedCard; + }, + + /** + * Update a card's FSRS scheduling data after review + */ + async updateScheduling( + id: string, + data: Pick< + LocalCard, + | "state" + | "due" + | "stability" + | "difficulty" + | "elapsedDays" + | "scheduledDays" + | "reps" + | "lapses" + | "lastReview" + >, + ): Promise<LocalCard | undefined> { + const card = await db.cards.get(id); + if (!card) return undefined; + + const updatedCard: LocalCard = { + ...card, + ...data, + updatedAt: new Date(), + _synced: false, + }; + await db.cards.put(updatedCard); + return updatedCard; + }, + + /** + * Soft delete a card + */ + async delete(id: string): Promise<boolean> { + const card = await db.cards.get(id); + if (!card) return false; + + await db.cards.update(id, { + deletedAt: new Date(), + updatedAt: new Date(), + _synced: false, + }); + return true; + }, + + /** + * Get all unsynced cards + */ + async findUnsynced(): Promise<LocalCard[]> { + return db.cards.filter((card) => !card._synced).toArray(); + }, + + /** + * Mark a card as synced + */ + async markSynced(id: string, syncVersion: number): Promise<void> { + await db.cards.update(id, { _synced: true, syncVersion }); + }, + + /** + * Upsert a card from server (for sync pull) + */ + async upsertFromServer(card: LocalCard): Promise<void> { + await db.cards.put({ ...card, _synced: true }); + }, +}; + +/** + * Local review log repository for IndexedDB operations + */ +export const localReviewLogRepository = { + /** + * Get all review logs for a card + */ + async findByCardId(cardId: string): Promise<LocalReviewLog[]> { + return db.reviewLogs.where("cardId").equals(cardId).toArray(); + }, + + /** + * Get all review logs for a user + */ + async findByUserId(userId: string): Promise<LocalReviewLog[]> { + return db.reviewLogs.where("userId").equals(userId).toArray(); + }, + + /** + * Get a review log by ID + */ + async findById(id: string): Promise<LocalReviewLog | undefined> { + return db.reviewLogs.get(id); + }, + + /** + * Create a new review log + */ + async create( + data: Omit<LocalReviewLog, "id" | "syncVersion" | "_synced">, + ): Promise<LocalReviewLog> { + const reviewLog: LocalReviewLog = { + id: uuidv4(), + ...data, + syncVersion: 0, + _synced: false, + }; + await db.reviewLogs.add(reviewLog); + return reviewLog; + }, + + /** + * Get all unsynced review logs + */ + async findUnsynced(): Promise<LocalReviewLog[]> { + return db.reviewLogs.filter((log) => !log._synced).toArray(); + }, + + /** + * Mark a review log as synced + */ + async markSynced(id: string, syncVersion: number): Promise<void> { + await db.reviewLogs.update(id, { _synced: true, syncVersion }); + }, + + /** + * Upsert a review log from server (for sync pull) + */ + async upsertFromServer(reviewLog: LocalReviewLog): Promise<void> { + await db.reviewLogs.put({ ...reviewLog, _synced: true }); + }, + + /** + * Get review logs within a date range + */ + async findByDateRange( + userId: string, + startDate: Date, + endDate: Date, + ): Promise<LocalReviewLog[]> { + return db.reviewLogs + .where("userId") + .equals(userId) + .filter((log) => log.reviewedAt >= startDate && log.reviewedAt <= endDate) + .toArray(); + }, +}; |
