aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 19:08:58 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 19:08:58 +0900
commitc086c8b35b6c6f0b0e2623e9b6421713a540941a (patch)
treeaa6937e799f88d497ce6bcae5bb347a945c77d27
parentd91888da7199cdde7662910debfffaa758b8a128 (diff)
downloadkioku-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.md6
-rw-r--r--package.json1
-rw-r--r--pnpm-lock.yaml9
-rw-r--r--src/client/db/repositories.test.ts581
-rw-r--r--src/client/db/repositories.ts379
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();
+ },
+};