diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 18:44:05 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 18:44:05 +0900 |
| commit | b965d9432b4037dd2f65bb4c8690965e090228ca (patch) | |
| tree | d9ad4da71d1f2bdc98e7b1f96b6efaeb58399efc | |
| parent | c2609af9d8bac65d3e70b3860160ac8bfe097241 (diff) | |
| download | kioku-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.md | 8 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 9 | ||||
| -rw-r--r-- | src/server/index.ts | 5 | ||||
| -rw-r--r-- | src/server/repositories/card.ts | 63 | ||||
| -rw-r--r-- | src/server/repositories/index.ts | 1 | ||||
| -rw-r--r-- | src/server/repositories/review-log.ts | 32 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 41 | ||||
| -rw-r--r-- | src/server/routes/cards.test.ts | 2 | ||||
| -rw-r--r-- | src/server/routes/index.ts | 1 | ||||
| -rw-r--r-- | src/server/routes/study.test.ts | 538 | ||||
| -rw-r--r-- | src/server/routes/study.ts | 139 |
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, +}); |
