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 /src/server/routes/study.ts | |
| 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>
Diffstat (limited to 'src/server/routes/study.ts')
| -rw-r--r-- | src/server/routes/study.ts | 139 |
1 files changed, 139 insertions, 0 deletions
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, +}); |
