aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 19:03:12 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 19:03:12 +0900
commitd91888da7199cdde7662910debfffaa758b8a128 (patch)
tree6f31882306d6908286bf455fd1c5979ce6a43cf5
parent463952d9ab01b80f71d7e32f98da908723303079 (diff)
downloadkioku-d91888da7199cdde7662910debfffaa758b8a128.tar.gz
kioku-d91888da7199cdde7662910debfffaa758b8a128.tar.zst
kioku-d91888da7199cdde7662910debfffaa758b8a128.zip
feat(client): add Dexie.js setup for IndexedDB local storage
Set up Dexie database with LocalDeck, LocalCard, and LocalReviewLog tables for offline support. Each entity includes _synced flag for tracking synchronization status. 🤖 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--package.json2
-rw-r--r--pnpm-lock.yaml17
-rw-r--r--src/client/db/index.test.ts307
-rw-r--r--src/client/db/index.ts129
5 files changed, 456 insertions, 1 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 3cba614..ee7ec63 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -147,7 +147,7 @@ Smaller features first to enable early MVP validation.
- [x] Add tests
### IndexedDB (Local Storage)
-- [ ] Dexie.js setup
+- [x] Dexie.js setup
- [ ] Local schema (with _synced flag)
- [ ] Local CRUD operations for Deck/Card/ReviewLog
- [ ] Add tests
diff --git a/package.json b/package.json
index fcb1d31..727f63d 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"@hono/node-server": "^1.19.6",
"@hono/zod-validator": "^0.7.5",
"argon2": "^0.44.0",
+ "dexie": "^4.2.1",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.0",
"hono": "^4.10.7",
@@ -54,6 +55,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"esbuild": "^0.27.1",
+ "fake-indexeddb": "^6.2.5",
"jsdom": "^27.2.0",
"typescript": "^5.9.3",
"vite": "^7.2.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2962018..c98ada1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
argon2:
specifier: ^0.44.0
version: 0.44.0
+ dexie:
+ specifier: ^4.2.1
+ version: 4.2.1
drizzle-kit:
specifier: ^0.31.8
version: 0.31.8
@@ -78,6 +81,9 @@ importers:
esbuild:
specifier: ^0.27.1
version: 0.27.1
+ fake-indexeddb:
+ specifier: ^6.2.5
+ version: 6.2.5
jsdom:
specifier: ^27.2.0
version: 27.2.0
@@ -1703,6 +1709,9 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
+ dexie@4.2.1:
+ resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==}
+
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
@@ -1895,6 +1904,10 @@ packages:
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
engines: {node: '>=12.0.0'}
+ fake-indexeddb@6.2.5:
+ resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
+ engines: {node: '>=18'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -4489,6 +4502,8 @@ snapshots:
dequal@2.0.3: {}
+ dexie@4.2.1: {}
+
dom-accessibility-api@0.5.16: {}
drizzle-kit@0.31.8:
@@ -4709,6 +4724,8 @@ snapshots:
expect-type@1.2.2: {}
+ fake-indexeddb@6.2.5: {}
+
fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {}
diff --git a/src/client/db/index.test.ts b/src/client/db/index.test.ts
new file mode 100644
index 0000000..c1a62f5
--- /dev/null
+++ b/src/client/db/index.test.ts
@@ -0,0 +1,307 @@
+/**
+ * @vitest-environment jsdom
+ */
+import "fake-indexeddb/auto";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ CardState,
+ db,
+ type LocalCard,
+ type LocalDeck,
+ type LocalReviewLog,
+ Rating,
+} from "./index";
+
+describe("KiokuDatabase", () => {
+ beforeEach(async () => {
+ // Clear database before each test
+ await db.decks.clear();
+ await db.cards.clear();
+ await db.reviewLogs.clear();
+ });
+
+ afterEach(async () => {
+ // Clean up after tests
+ await db.decks.clear();
+ await db.cards.clear();
+ await db.reviewLogs.clear();
+ });
+
+ describe("database initialization", () => {
+ it("should have decks table", () => {
+ expect(db.decks).toBeDefined();
+ });
+
+ it("should have cards table", () => {
+ expect(db.cards).toBeDefined();
+ });
+
+ it("should have reviewLogs table", () => {
+ expect(db.reviewLogs).toBeDefined();
+ });
+
+ it("should be named kioku", () => {
+ expect(db.name).toBe("kioku");
+ });
+ });
+
+ describe("decks table", () => {
+ const testDeck: LocalDeck = {
+ id: "deck-1",
+ userId: "user-1",
+ name: "Test Deck",
+ description: "A test deck",
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ _synced: false,
+ };
+
+ it("should add and retrieve a deck", async () => {
+ await db.decks.add(testDeck);
+ const retrieved = await db.decks.get("deck-1");
+ expect(retrieved).toEqual(testDeck);
+ });
+
+ it("should find unsynced decks", async () => {
+ await db.decks.add(testDeck);
+ await db.decks.add({
+ ...testDeck,
+ id: "deck-2",
+ name: "Synced Deck",
+ _synced: true,
+ });
+
+ const unsynced = await db.decks.filter((d) => !d._synced).toArray();
+ expect(unsynced).toHaveLength(1);
+ expect(unsynced[0]?.id).toBe("deck-1");
+ });
+
+ it("should find decks by userId", async () => {
+ await db.decks.add(testDeck);
+ await db.decks.add({
+ ...testDeck,
+ id: "deck-2",
+ userId: "user-2",
+ });
+
+ const userDecks = await db.decks
+ .where("userId")
+ .equals("user-1")
+ .toArray();
+ expect(userDecks).toHaveLength(1);
+ expect(userDecks[0]?.id).toBe("deck-1");
+ });
+
+ it("should update a deck", async () => {
+ await db.decks.add(testDeck);
+ await db.decks.update("deck-1", {
+ name: "Updated Deck",
+ _synced: false,
+ });
+
+ const updated = await db.decks.get("deck-1");
+ expect(updated?.name).toBe("Updated Deck");
+ });
+
+ it("should delete a deck", async () => {
+ await db.decks.add(testDeck);
+ await db.decks.delete("deck-1");
+
+ const deleted = await db.decks.get("deck-1");
+ expect(deleted).toBeUndefined();
+ });
+ });
+
+ describe("cards table", () => {
+ const testCard: LocalCard = {
+ id: "card-1",
+ deckId: "deck-1",
+ front: "Question",
+ back: "Answer",
+ state: CardState.New,
+ due: new Date("2024-01-01"),
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ _synced: false,
+ };
+
+ it("should add and retrieve a card", async () => {
+ await db.cards.add(testCard);
+ const retrieved = await db.cards.get("card-1");
+ expect(retrieved).toEqual(testCard);
+ });
+
+ it("should find cards by deckId", async () => {
+ await db.cards.add(testCard);
+ await db.cards.add({
+ ...testCard,
+ id: "card-2",
+ deckId: "deck-2",
+ });
+
+ const deckCards = await db.cards
+ .where("deckId")
+ .equals("deck-1")
+ .toArray();
+ expect(deckCards).toHaveLength(1);
+ expect(deckCards[0]?.id).toBe("card-1");
+ });
+
+ it("should find due cards", async () => {
+ const now = new Date();
+ const past = new Date(now.getTime() - 1000 * 60 * 60);
+ const future = new Date(now.getTime() + 1000 * 60 * 60);
+
+ await db.cards.add({ ...testCard, id: "card-past", due: past });
+ await db.cards.add({ ...testCard, id: "card-future", due: future });
+
+ const dueCards = await db.cards.where("due").belowOrEqual(now).toArray();
+ expect(dueCards).toHaveLength(1);
+ expect(dueCards[0]?.id).toBe("card-past");
+ });
+
+ it("should find cards by state", async () => {
+ await db.cards.add(testCard);
+ await db.cards.add({
+ ...testCard,
+ id: "card-2",
+ state: CardState.Review,
+ });
+
+ const newCards = await db.cards
+ .where("state")
+ .equals(CardState.New)
+ .toArray();
+ expect(newCards).toHaveLength(1);
+ expect(newCards[0]?.id).toBe("card-1");
+ });
+
+ it("should find unsynced cards", async () => {
+ await db.cards.add(testCard);
+ await db.cards.add({
+ ...testCard,
+ id: "card-2",
+ _synced: true,
+ });
+
+ const unsynced = await db.cards.filter((c) => !c._synced).toArray();
+ expect(unsynced).toHaveLength(1);
+ expect(unsynced[0]?.id).toBe("card-1");
+ });
+ });
+
+ describe("reviewLogs table", () => {
+ const testReviewLog: LocalReviewLog = {
+ id: "review-1",
+ cardId: "card-1",
+ userId: "user-1",
+ rating: Rating.Good,
+ state: CardState.New,
+ scheduledDays: 1,
+ elapsedDays: 0,
+ reviewedAt: new Date("2024-01-01"),
+ durationMs: 5000,
+ syncVersion: 0,
+ _synced: false,
+ };
+
+ it("should add and retrieve a review log", async () => {
+ await db.reviewLogs.add(testReviewLog);
+ const retrieved = await db.reviewLogs.get("review-1");
+ expect(retrieved).toEqual(testReviewLog);
+ });
+
+ it("should find review logs by cardId", async () => {
+ await db.reviewLogs.add(testReviewLog);
+ await db.reviewLogs.add({
+ ...testReviewLog,
+ id: "review-2",
+ cardId: "card-2",
+ });
+
+ const cardReviews = await db.reviewLogs
+ .where("cardId")
+ .equals("card-1")
+ .toArray();
+ expect(cardReviews).toHaveLength(1);
+ expect(cardReviews[0]?.id).toBe("review-1");
+ });
+
+ it("should find review logs by userId", async () => {
+ await db.reviewLogs.add(testReviewLog);
+ await db.reviewLogs.add({
+ ...testReviewLog,
+ id: "review-2",
+ userId: "user-2",
+ });
+
+ const userReviews = await db.reviewLogs
+ .where("userId")
+ .equals("user-1")
+ .toArray();
+ expect(userReviews).toHaveLength(1);
+ expect(userReviews[0]?.id).toBe("review-1");
+ });
+
+ it("should find unsynced review logs", async () => {
+ await db.reviewLogs.add(testReviewLog);
+ await db.reviewLogs.add({
+ ...testReviewLog,
+ id: "review-2",
+ _synced: true,
+ });
+
+ const unsynced = await db.reviewLogs.filter((r) => !r._synced).toArray();
+ expect(unsynced).toHaveLength(1);
+ expect(unsynced[0]?.id).toBe("review-1");
+ });
+
+ it("should order review logs by reviewedAt", async () => {
+ const earlier = new Date("2024-01-01");
+ const later = new Date("2024-01-02");
+
+ await db.reviewLogs.add({
+ ...testReviewLog,
+ id: "review-later",
+ reviewedAt: later,
+ });
+ await db.reviewLogs.add({
+ ...testReviewLog,
+ id: "review-earlier",
+ reviewedAt: earlier,
+ });
+
+ const sorted = await db.reviewLogs.orderBy("reviewedAt").toArray();
+ expect(sorted[0]?.id).toBe("review-earlier");
+ expect(sorted[1]?.id).toBe("review-later");
+ });
+ });
+
+ describe("constants", () => {
+ it("should export CardState enum", () => {
+ expect(CardState.New).toBe(0);
+ expect(CardState.Learning).toBe(1);
+ expect(CardState.Review).toBe(2);
+ expect(CardState.Relearning).toBe(3);
+ });
+
+ it("should export Rating enum", () => {
+ expect(Rating.Again).toBe(1);
+ expect(Rating.Hard).toBe(2);
+ expect(Rating.Good).toBe(3);
+ expect(Rating.Easy).toBe(4);
+ });
+ });
+});
diff --git a/src/client/db/index.ts b/src/client/db/index.ts
new file mode 100644
index 0000000..4c381d2
--- /dev/null
+++ b/src/client/db/index.ts
@@ -0,0 +1,129 @@
+import Dexie, { type EntityTable } from "dexie";
+
+/**
+ * Card states for FSRS algorithm
+ */
+export const CardState = {
+ New: 0,
+ Learning: 1,
+ Review: 2,
+ Relearning: 3,
+} as const;
+
+export type CardStateType = (typeof CardState)[keyof typeof CardState];
+
+/**
+ * Rating values for reviews
+ */
+export const Rating = {
+ Again: 1,
+ Hard: 2,
+ Good: 3,
+ Easy: 4,
+} as const;
+
+export type RatingType = (typeof Rating)[keyof typeof Rating];
+
+/**
+ * Local deck stored in IndexedDB
+ * Includes _synced flag for offline sync tracking
+ */
+export interface LocalDeck {
+ id: string;
+ userId: string;
+ name: string;
+ description: string | null;
+ newCardsPerDay: number;
+ createdAt: Date;
+ updatedAt: Date;
+ deletedAt: Date | null;
+ syncVersion: number;
+ _synced: boolean;
+}
+
+/**
+ * Local card stored in IndexedDB
+ * Includes _synced flag for offline sync tracking
+ */
+export interface LocalCard {
+ id: string;
+ deckId: string;
+ front: string;
+ back: string;
+
+ // FSRS fields
+ state: CardStateType;
+ due: Date;
+ stability: number;
+ difficulty: number;
+ elapsedDays: number;
+ scheduledDays: number;
+ reps: number;
+ lapses: number;
+ lastReview: Date | null;
+
+ createdAt: Date;
+ updatedAt: Date;
+ deletedAt: Date | null;
+ syncVersion: number;
+ _synced: boolean;
+}
+
+/**
+ * Local review log stored in IndexedDB
+ * Includes _synced flag for offline sync tracking
+ * ReviewLog is append-only (no conflicts possible)
+ */
+export interface LocalReviewLog {
+ id: string;
+ cardId: string;
+ userId: string;
+ rating: RatingType;
+ state: CardStateType;
+ scheduledDays: number;
+ elapsedDays: number;
+ reviewedAt: Date;
+ durationMs: number | null;
+ syncVersion: number;
+ _synced: boolean;
+}
+
+/**
+ * Kioku local database using Dexie (IndexedDB wrapper)
+ *
+ * This database stores decks, cards, and review logs locally for offline support.
+ * Each entity has a _synced flag to track whether it has been synchronized with the server.
+ */
+export class KiokuDatabase extends Dexie {
+ decks!: EntityTable<LocalDeck, "id">;
+ cards!: EntityTable<LocalCard, "id">;
+ reviewLogs!: EntityTable<LocalReviewLog, "id">;
+
+ constructor() {
+ super("kioku");
+
+ this.version(1).stores({
+ // Primary key is 'id', indexed fields follow
+ // userId: for filtering by user
+ // updatedAt: for sync ordering
+ // Note: _synced is not indexed (boolean fields can't be indexed in IndexedDB)
+ // Use .filter() to find unsynced items
+ decks: "id, userId, updatedAt",
+
+ // deckId: for filtering cards by deck
+ // due: for finding due cards
+ // state: for filtering by card state
+ cards: "id, deckId, updatedAt, due, state",
+
+ // cardId: for finding reviews for a card
+ // userId: for filtering by user
+ // reviewedAt: for ordering reviews
+ reviewLogs: "id, cardId, userId, reviewedAt",
+ });
+ }
+}
+
+/**
+ * Singleton instance of the Kioku database
+ */
+export const db = new KiokuDatabase();