aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-07 18:44:05 +0900
committernsfisis <nsfisis@gmail.com>2025-12-07 18:44:05 +0900
commitb965d9432b4037dd2f65bb4c8690965e090228ca (patch)
treed9ad4da71d1f2bdc98e7b1f96b6efaeb58399efc
parentc2609af9d8bac65d3e70b3860160ac8bfe097241 (diff)
downloadkioku-b965d9432b4037dd2f65bb4c8690965e090228ca.tar.gz
kioku-b965d9432b4037dd2f65bb4c8690965e090228ca.tar.zst
kioku-b965d9432b4037dd2f65bb4c8690965e090228ca.zip
feat(server): add study session API with FSRS integration
Implement study endpoints for spaced repetition learning: - GET /api/decks/:deckId/study to fetch due cards - POST /api/decks/:deckId/study/:cardId to submit reviews - Integrate ts-fsrs library for scheduling algorithm - Add ReviewLog repository for tracking review history 🤖 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.md8
-rw-r--r--package.json1
-rw-r--r--pnpm-lock.yaml9
-rw-r--r--src/server/index.ts5
-rw-r--r--src/server/repositories/card.ts63
-rw-r--r--src/server/repositories/index.ts1
-rw-r--r--src/server/repositories/review-log.ts32
-rw-r--r--src/server/repositories/types.ts41
-rw-r--r--src/server/routes/cards.test.ts2
-rw-r--r--src/server/routes/index.ts1
-rw-r--r--src/server/routes/study.test.ts538
-rw-r--r--src/server/routes/study.ts139
12 files changed, 833 insertions, 7 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index 76e016a..40ef7e1 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -118,10 +118,10 @@ Smaller features first to enable early MVP validation.
**Goal**: Study with FSRS algorithm
### Server API
-- [ ] ts-fsrs integration
-- [ ] GET /api/decks/:deckId/study - Get due cards
-- [ ] POST /api/decks/:deckId/study/:cardId - Submit review
-- [ ] Add tests
+- [x] ts-fsrs integration
+- [x] GET /api/decks/:deckId/study - Get due cards
+- [x] POST /api/decks/:deckId/study/:cardId - Submit review
+- [x] Add tests
### Frontend
- [ ] Study session page
diff --git a/package.json b/package.json
index 55b7e0a..e8430ea 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,7 @@
"pg": "^8.16.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
+ "ts-fsrs": "^5.2.3",
"wouter": "^3.8.1",
"zod": "^4.1.13"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f41dfe9..419f011 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,9 @@ importers:
react-dom:
specifier: ^19.2.1
version: 19.2.1(react@19.2.1)
+ ts-fsrs:
+ specifier: ^5.2.3
+ version: 5.2.3
wouter:
specifier: ^3.8.1
version: 3.8.1(react@19.2.1)
@@ -1515,6 +1518,10 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
+ ts-fsrs@5.2.3:
+ resolution: {integrity: sha512-R3IjceC9WfnvUin6Nx+DwqEzh3Qil6Gg2yEHqvocUcC7Nbi+xDrFg/1fKaYBT0tJedDnDAguXMSX0hijhi859w==}
+ engines: {node: '>=18.0.0'}
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -2812,6 +2819,8 @@ snapshots:
dependencies:
punycode: 2.3.1
+ ts-fsrs@5.2.3: {}
+
typescript@5.9.3: {}
undici-types@7.16.0: {}
diff --git a/src/server/index.ts b/src/server/index.ts
index d00564f..0391119 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -2,7 +2,7 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { errorHandler } from "./middleware/index.js";
-import { auth, cards, decks } from "./routes/index.js";
+import { auth, cards, decks, study } from "./routes/index.js";
const app = new Hono();
@@ -19,7 +19,8 @@ const routes = app
})
.route("/api/auth", auth)
.route("/api/decks", decks)
- .route("/api/decks/:deckId/cards", cards);
+ .route("/api/decks/:deckId/cards", cards)
+ .route("/api/decks/:deckId/study", study);
export type AppType = typeof routes;
diff --git a/src/server/repositories/card.ts b/src/server/repositories/card.ts
index 0a47c50..76c9d30 100644
--- a/src/server/repositories/card.ts
+++ b/src/server/repositories/card.ts
@@ -1,4 +1,4 @@
-import { and, eq, isNull, sql } from "drizzle-orm";
+import { and, eq, isNull, lte, sql } from "drizzle-orm";
import { db } from "../db/index.js";
import { CardState, cards } from "../db/schema.js";
import type { Card, CardRepository } from "./types.js";
@@ -99,4 +99,65 @@ export const cardRepository: CardRepository = {
.returning({ id: cards.id });
return result.length > 0;
},
+
+ async findDueCards(
+ deckId: string,
+ now: Date,
+ limit: number,
+ ): Promise<Card[]> {
+ const result = await db
+ .select()
+ .from(cards)
+ .where(
+ and(
+ eq(cards.deckId, deckId),
+ isNull(cards.deletedAt),
+ lte(cards.due, now),
+ ),
+ )
+ .orderBy(cards.due)
+ .limit(limit);
+ return result;
+ },
+
+ async updateFSRSFields(
+ id: string,
+ deckId: string,
+ data: {
+ state: number;
+ due: Date;
+ stability: number;
+ difficulty: number;
+ elapsedDays: number;
+ scheduledDays: number;
+ reps: number;
+ lapses: number;
+ lastReview: Date;
+ },
+ ): Promise<Card | undefined> {
+ const result = await db
+ .update(cards)
+ .set({
+ state: data.state,
+ due: data.due,
+ stability: data.stability,
+ difficulty: data.difficulty,
+ elapsedDays: data.elapsedDays,
+ scheduledDays: data.scheduledDays,
+ reps: data.reps,
+ lapses: data.lapses,
+ lastReview: data.lastReview,
+ updatedAt: new Date(),
+ syncVersion: sql`${cards.syncVersion} + 1`,
+ })
+ .where(
+ and(
+ eq(cards.id, id),
+ eq(cards.deckId, deckId),
+ isNull(cards.deletedAt),
+ ),
+ )
+ .returning();
+ return result[0];
+ },
};
diff --git a/src/server/repositories/index.ts b/src/server/repositories/index.ts
index 298666e..e909a9b 100644
--- a/src/server/repositories/index.ts
+++ b/src/server/repositories/index.ts
@@ -1,5 +1,6 @@
export { cardRepository } from "./card.js";
export { deckRepository } from "./deck.js";
export { refreshTokenRepository } from "./refresh-token.js";
+export { reviewLogRepository } from "./review-log.js";
export * from "./types.js";
export { userRepository } from "./user.js";
diff --git a/src/server/repositories/review-log.ts b/src/server/repositories/review-log.ts
new file mode 100644
index 0000000..c8950d6
--- /dev/null
+++ b/src/server/repositories/review-log.ts
@@ -0,0 +1,32 @@
+import { db } from "../db/index.js";
+import { reviewLogs } from "../db/schema.js";
+import type { ReviewLog, ReviewLogRepository } from "./types.js";
+
+export const reviewLogRepository: ReviewLogRepository = {
+ async create(data: {
+ cardId: string;
+ userId: string;
+ rating: number;
+ state: number;
+ scheduledDays: number;
+ elapsedDays: number;
+ durationMs?: number | null;
+ }): Promise<ReviewLog> {
+ const [reviewLog] = await db
+ .insert(reviewLogs)
+ .values({
+ cardId: data.cardId,
+ userId: data.userId,
+ rating: data.rating,
+ state: data.state,
+ scheduledDays: data.scheduledDays,
+ elapsedDays: data.elapsedDays,
+ durationMs: data.durationMs ?? null,
+ })
+ .returning();
+ if (!reviewLog) {
+ throw new Error("Failed to create review log");
+ }
+ return reviewLog;
+ },
+};
diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts
index 1e8ba21..c5ca946 100644
--- a/src/server/repositories/types.ts
+++ b/src/server/repositories/types.ts
@@ -120,4 +120,45 @@ export interface CardRepository {
},
): Promise<Card | undefined>;
softDelete(id: string, deckId: string): Promise<boolean>;
+ findDueCards(deckId: string, now: Date, limit: number): Promise<Card[]>;
+ updateFSRSFields(
+ id: string,
+ deckId: string,
+ data: {
+ state: number;
+ due: Date;
+ stability: number;
+ difficulty: number;
+ elapsedDays: number;
+ scheduledDays: number;
+ reps: number;
+ lapses: number;
+ lastReview: Date;
+ },
+ ): Promise<Card | undefined>;
+}
+
+export interface ReviewLog {
+ id: string;
+ cardId: string;
+ userId: string;
+ rating: number;
+ state: number;
+ scheduledDays: number;
+ elapsedDays: number;
+ reviewedAt: Date;
+ durationMs: number | null;
+ syncVersion: number;
+}
+
+export interface ReviewLogRepository {
+ create(data: {
+ cardId: string;
+ userId: string;
+ rating: number;
+ state: number;
+ scheduledDays: number;
+ elapsedDays: number;
+ durationMs?: number | null;
+ }): Promise<ReviewLog>;
}
diff --git a/src/server/routes/cards.test.ts b/src/server/routes/cards.test.ts
index 1d01cff..d319b33 100644
--- a/src/server/routes/cards.test.ts
+++ b/src/server/routes/cards.test.ts
@@ -18,6 +18,8 @@ function createMockCardRepo(): CardRepository {
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
+ findDueCards: vi.fn(),
+ updateFSRSFields: vi.fn(),
};
}
diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts
index 009f2c3..e702044 100644
--- a/src/server/routes/index.ts
+++ b/src/server/routes/index.ts
@@ -1,3 +1,4 @@
export { auth } from "./auth.js";
export { cards } from "./cards.js";
export { decks } from "./decks.js";
+export { study } from "./study.js";
diff --git a/src/server/routes/study.test.ts b/src/server/routes/study.test.ts
new file mode 100644
index 0000000..6c45d3a
--- /dev/null
+++ b/src/server/routes/study.test.ts
@@ -0,0 +1,538 @@
+import { Hono } from "hono";
+import { sign } from "hono/jwt";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CardState, Rating } from "../db/schema.js";
+import { errorHandler } from "../middleware/index.js";
+import type {
+ Card,
+ CardRepository,
+ Deck,
+ DeckRepository,
+ ReviewLog,
+ ReviewLogRepository,
+} from "../repositories/index.js";
+import { createStudyRouter } from "./study.js";
+
+function createMockCardRepo(): CardRepository {
+ return {
+ findByDeckId: vi.fn(),
+ findById: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ softDelete: vi.fn(),
+ findDueCards: vi.fn(),
+ updateFSRSFields: vi.fn(),
+ };
+}
+
+function createMockDeckRepo(): DeckRepository {
+ return {
+ findByUserId: vi.fn(),
+ findById: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ softDelete: vi.fn(),
+ };
+}
+
+function createMockReviewLogRepo(): ReviewLogRepository {
+ return {
+ create: vi.fn(),
+ };
+}
+
+const JWT_SECRET = process.env.JWT_SECRET || "test-secret";
+
+async function createTestToken(userId: string): Promise<string> {
+ const now = Math.floor(Date.now() / 1000);
+ return sign(
+ {
+ sub: userId,
+ iat: now,
+ exp: now + 900,
+ },
+ JWT_SECRET,
+ );
+}
+
+function createMockDeck(overrides: Partial<Deck> = {}): Deck {
+ return {
+ id: "deck-uuid-123",
+ userId: "user-uuid-123",
+ name: "Test Deck",
+ description: "Test description",
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ ...overrides,
+ };
+}
+
+function createMockCard(overrides: Partial<Card> = {}): Card {
+ return {
+ id: "card-uuid-123",
+ deckId: "deck-uuid-123",
+ 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,
+ ...overrides,
+ };
+}
+
+function createMockReviewLog(overrides: Partial<ReviewLog> = {}): ReviewLog {
+ return {
+ id: "review-log-uuid-123",
+ cardId: "card-uuid-123",
+ userId: "user-uuid-123",
+ rating: Rating.Good,
+ state: CardState.New,
+ scheduledDays: 1,
+ elapsedDays: 0,
+ reviewedAt: new Date("2024-01-01"),
+ durationMs: null,
+ syncVersion: 0,
+ ...overrides,
+ };
+}
+
+interface StudyResponse {
+ card?: Card;
+ cards?: Card[];
+ error?: {
+ code: string;
+ message: string;
+ };
+}
+
+const DECK_ID = "00000000-0000-4000-8000-000000000001";
+const CARD_ID = "00000000-0000-4000-8000-000000000002";
+
+describe("GET /api/decks/:deckId/study", () => {
+ let app: Hono;
+ let mockCardRepo: ReturnType<typeof createMockCardRepo>;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let mockReviewLogRepo: ReturnType<typeof createMockReviewLogRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockCardRepo = createMockCardRepo();
+ mockDeckRepo = createMockDeckRepo();
+ mockReviewLogRepo = createMockReviewLogRepo();
+ const studyRouter = createStudyRouter({
+ cardRepo: mockCardRepo,
+ deckRepo: mockDeckRepo,
+ reviewLogRepo: mockReviewLogRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks/:deckId/study", studyRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("returns empty array when no cards are due", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockCardRepo.findDueCards).mockResolvedValue([]);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as StudyResponse;
+ expect(body.cards).toEqual([]);
+ expect(mockDeckRepo.findById).toHaveBeenCalledWith(
+ DECK_ID,
+ "user-uuid-123",
+ );
+ expect(mockCardRepo.findDueCards).toHaveBeenCalledWith(
+ DECK_ID,
+ expect.any(Date),
+ 100,
+ );
+ });
+
+ it("returns due cards", async () => {
+ const mockCards = [
+ createMockCard({ id: "card-1", front: "Q1", back: "A1" }),
+ createMockCard({ id: "card-2", front: "Q2", back: "A2" }),
+ ];
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockCardRepo.findDueCards).mockResolvedValue(mockCards);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as StudyResponse;
+ expect(body.cards).toHaveLength(2);
+ });
+
+ it("returns 404 for non-existent deck", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study`, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as StudyResponse;
+ expect(body.error?.code).toBe("DECK_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid deck uuid", async () => {
+ const res = await app.request("/api/decks/invalid-id/study", {
+ method: "GET",
+ headers: { Authorization: `Bearer ${authToken}` },
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request(`/api/decks/${DECK_ID}/study`, {
+ method: "GET",
+ });
+
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("POST /api/decks/:deckId/study/:cardId", () => {
+ let app: Hono;
+ let mockCardRepo: ReturnType<typeof createMockCardRepo>;
+ let mockDeckRepo: ReturnType<typeof createMockDeckRepo>;
+ let mockReviewLogRepo: ReturnType<typeof createMockReviewLogRepo>;
+ let authToken: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockCardRepo = createMockCardRepo();
+ mockDeckRepo = createMockDeckRepo();
+ mockReviewLogRepo = createMockReviewLogRepo();
+ const studyRouter = createStudyRouter({
+ cardRepo: mockCardRepo,
+ deckRepo: mockDeckRepo,
+ reviewLogRepo: mockReviewLogRepo,
+ });
+ app = new Hono();
+ app.onError(errorHandler);
+ app.route("/api/decks/:deckId/study", studyRouter);
+ authToken = await createTestToken("user-uuid-123");
+ });
+
+ it("submits a review with rating Good", async () => {
+ const card = createMockCard({ id: CARD_ID, deckId: DECK_ID });
+ const updatedCard = createMockCard({
+ id: CARD_ID,
+ deckId: DECK_ID,
+ state: CardState.Learning,
+ reps: 1,
+ });
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockCardRepo.findById).mockResolvedValue(card);
+ vi.mocked(mockCardRepo.updateFSRSFields).mockResolvedValue(updatedCard);
+ vi.mocked(mockReviewLogRepo.create).mockResolvedValue(
+ createMockReviewLog(),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study/${CARD_ID}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ rating: Rating.Good }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as StudyResponse;
+ expect(body.card).toBeDefined();
+ expect(mockCardRepo.updateFSRSFields).toHaveBeenCalledWith(
+ CARD_ID,
+ DECK_ID,
+ expect.objectContaining({
+ state: expect.any(Number),
+ due: expect.any(Date),
+ stability: expect.any(Number),
+ difficulty: expect.any(Number),
+ }),
+ );
+ expect(mockReviewLogRepo.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ cardId: CARD_ID,
+ userId: "user-uuid-123",
+ rating: Rating.Good,
+ }),
+ );
+ });
+
+ it("submits a review with rating Again", async () => {
+ const card = createMockCard({ id: CARD_ID, deckId: DECK_ID });
+ const updatedCard = createMockCard({
+ id: CARD_ID,
+ deckId: DECK_ID,
+ state: CardState.Learning,
+ });
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockCardRepo.findById).mockResolvedValue(card);
+ vi.mocked(mockCardRepo.updateFSRSFields).mockResolvedValue(updatedCard);
+ vi.mocked(mockReviewLogRepo.create).mockResolvedValue(
+ createMockReviewLog(),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study/${CARD_ID}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ rating: Rating.Again }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(mockCardRepo.updateFSRSFields).toHaveBeenCalled();
+ });
+
+ it("submits a review with rating Hard", async () => {
+ const card = createMockCard({ id: CARD_ID, deckId: DECK_ID });
+ const updatedCard = createMockCard({ id: CARD_ID, deckId: DECK_ID });
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockCardRepo.findById).mockResolvedValue(card);
+ vi.mocked(mockCardRepo.updateFSRSFields).mockResolvedValue(updatedCard);
+ vi.mocked(mockReviewLogRepo.create).mockResolvedValue(
+ createMockReviewLog(),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study/${CARD_ID}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ rating: Rating.Hard }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(mockCardRepo.updateFSRSFields).toHaveBeenCalled();
+ });
+
+ it("submits a review with rating Easy", async () => {
+ const card = createMockCard({ id: CARD_ID, deckId: DECK_ID });
+ const updatedCard = createMockCard({ id: CARD_ID, deckId: DECK_ID });
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockCardRepo.findById).mockResolvedValue(card);
+ vi.mocked(mockCardRepo.updateFSRSFields).mockResolvedValue(updatedCard);
+ vi.mocked(mockReviewLogRepo.create).mockResolvedValue(
+ createMockReviewLog(),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study/${CARD_ID}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ rating: Rating.Easy }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(mockCardRepo.updateFSRSFields).toHaveBeenCalled();
+ });
+
+ it("includes durationMs in review log when provided", async () => {
+ const card = createMockCard({ id: CARD_ID, deckId: DECK_ID });
+ const updatedCard = createMockCard({ id: CARD_ID, deckId: DECK_ID });
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockCardRepo.findById).mockResolvedValue(card);
+ vi.mocked(mockCardRepo.updateFSRSFields).mockResolvedValue(updatedCard);
+ vi.mocked(mockReviewLogRepo.create).mockResolvedValue(
+ createMockReviewLog(),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study/${CARD_ID}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ rating: Rating.Good, durationMs: 5000 }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(mockReviewLogRepo.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ durationMs: 5000,
+ }),
+ );
+ });
+
+ it("returns 404 for non-existent deck", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study/${CARD_ID}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ rating: Rating.Good }),
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as StudyResponse;
+ expect(body.error?.code).toBe("DECK_NOT_FOUND");
+ });
+
+ it("returns 404 for non-existent card", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockCardRepo.findById).mockResolvedValue(undefined);
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study/${CARD_ID}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ rating: Rating.Good }),
+ });
+
+ expect(res.status).toBe(404);
+ const body = (await res.json()) as StudyResponse;
+ expect(body.error?.code).toBe("CARD_NOT_FOUND");
+ });
+
+ it("returns 400 for invalid rating", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study/${CARD_ID}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ rating: 5 }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for missing rating", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study/${CARD_ID}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 400 for invalid card uuid", async () => {
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study/invalid-id`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ rating: Rating.Good }),
+ });
+
+ expect(res.status).toBe(400);
+ });
+
+ it("returns 401 when not authenticated", async () => {
+ const res = await app.request(`/api/decks/${DECK_ID}/study/${CARD_ID}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rating: Rating.Good }),
+ });
+
+ expect(res.status).toBe(401);
+ });
+
+ it("handles card with previous reviews", async () => {
+ const lastReviewDate = new Date("2024-01-01");
+ const card = createMockCard({
+ id: CARD_ID,
+ deckId: DECK_ID,
+ state: CardState.Review,
+ lastReview: lastReviewDate,
+ reps: 5,
+ stability: 10,
+ difficulty: 5,
+ });
+ const updatedCard = createMockCard({
+ id: CARD_ID,
+ deckId: DECK_ID,
+ state: CardState.Review,
+ });
+ vi.mocked(mockDeckRepo.findById).mockResolvedValue(
+ createMockDeck({ id: DECK_ID }),
+ );
+ vi.mocked(mockCardRepo.findById).mockResolvedValue(card);
+ vi.mocked(mockCardRepo.updateFSRSFields).mockResolvedValue(updatedCard);
+ vi.mocked(mockReviewLogRepo.create).mockResolvedValue(
+ createMockReviewLog(),
+ );
+
+ const res = await app.request(`/api/decks/${DECK_ID}/study/${CARD_ID}`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ rating: Rating.Good }),
+ });
+
+ expect(res.status).toBe(200);
+ expect(mockCardRepo.updateFSRSFields).toHaveBeenCalled();
+ expect(mockReviewLogRepo.create).toHaveBeenCalled();
+ });
+});
diff --git a/src/server/routes/study.ts b/src/server/routes/study.ts
new file mode 100644
index 0000000..6a5d09d
--- /dev/null
+++ b/src/server/routes/study.ts
@@ -0,0 +1,139 @@
+import { zValidator } from "@hono/zod-validator";
+import { Hono } from "hono";
+import {
+ type Card as FSRSCard,
+ type State as FSRSState,
+ fsrs,
+ type Grade,
+} from "ts-fsrs";
+import { z } from "zod";
+import { authMiddleware, Errors, getAuthUser } from "../middleware/index.js";
+import {
+ type CardRepository,
+ cardRepository,
+ type DeckRepository,
+ deckRepository,
+ type ReviewLogRepository,
+ reviewLogRepository,
+} from "../repositories/index.js";
+import { submitReviewSchema } from "../schemas/index.js";
+
+export interface StudyDependencies {
+ cardRepo: CardRepository;
+ deckRepo: DeckRepository;
+ reviewLogRepo: ReviewLogRepository;
+}
+
+const deckIdParamSchema = z.object({
+ deckId: z.string().uuid(),
+});
+
+const cardIdParamSchema = z.object({
+ deckId: z.string().uuid(),
+ cardId: z.string().uuid(),
+});
+
+const f = fsrs();
+
+export function createStudyRouter(deps: StudyDependencies) {
+ const { cardRepo, deckRepo, reviewLogRepo } = deps;
+
+ return new Hono()
+ .use("*", authMiddleware)
+ .get("/", zValidator("param", deckIdParamSchema), async (c) => {
+ const user = getAuthUser(c);
+ const { deckId } = c.req.valid("param");
+
+ // Verify deck ownership
+ const deck = await deckRepo.findById(deckId, user.id);
+ if (!deck) {
+ throw Errors.notFound("Deck not found", "DECK_NOT_FOUND");
+ }
+
+ const now = new Date();
+ const dueCards = await cardRepo.findDueCards(deckId, now, 100);
+
+ return c.json({ cards: dueCards }, 200);
+ })
+ .post(
+ "/:cardId",
+ zValidator("param", cardIdParamSchema),
+ zValidator("json", submitReviewSchema),
+ async (c) => {
+ const user = getAuthUser(c);
+ const { deckId, cardId } = c.req.valid("param");
+ const { rating, durationMs } = c.req.valid("json");
+
+ // Verify deck ownership
+ const deck = await deckRepo.findById(deckId, user.id);
+ if (!deck) {
+ throw Errors.notFound("Deck not found", "DECK_NOT_FOUND");
+ }
+
+ // Get the card
+ const card = await cardRepo.findById(cardId, deckId);
+ if (!card) {
+ throw Errors.notFound("Card not found", "CARD_NOT_FOUND");
+ }
+
+ const now = new Date();
+
+ // Convert our card to FSRS card format
+ const fsrsCard: FSRSCard = {
+ due: card.due,
+ stability: card.stability,
+ difficulty: card.difficulty,
+ elapsed_days: card.elapsedDays,
+ scheduled_days: card.scheduledDays,
+ reps: card.reps,
+ lapses: card.lapses,
+ state: card.state as FSRSState,
+ last_review: card.lastReview ?? undefined,
+ learning_steps: 0,
+ };
+
+ // Schedule the card with the given rating
+ const result = f.next(fsrsCard, now, rating as Grade);
+
+ // Calculate elapsed days for review log
+ const elapsedDays = card.lastReview
+ ? Math.round(
+ (now.getTime() - card.lastReview.getTime()) /
+ (1000 * 60 * 60 * 24),
+ )
+ : 0;
+
+ // Update the card with new FSRS values
+ const updatedCard = await cardRepo.updateFSRSFields(cardId, deckId, {
+ state: result.card.state,
+ due: result.card.due,
+ stability: result.card.stability,
+ difficulty: result.card.difficulty,
+ elapsedDays: result.card.elapsed_days,
+ scheduledDays: result.card.scheduled_days,
+ reps: result.card.reps,
+ lapses: result.card.lapses,
+ lastReview: now,
+ });
+
+ // Create review log
+ await reviewLogRepo.create({
+ cardId,
+ userId: user.id,
+ rating,
+ state: card.state,
+ scheduledDays: result.card.scheduled_days,
+ elapsedDays,
+ durationMs: durationMs ?? null,
+ });
+
+ return c.json({ card: updatedCard }, 200);
+ },
+ );
+}
+
+export const study = createStudyRouter({
+ cardRepo: cardRepository,
+ deckRepo: deckRepository,
+ reviewLogRepo: reviewLogRepository,
+});