aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server/routes/study.ts
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 /src/server/routes/study.ts
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>
Diffstat (limited to 'src/server/routes/study.ts')
-rw-r--r--src/server/routes/study.ts139
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,
+});