feat(cards): multiple-choice Card-Type mit dynamischen Distractors

- CardTypeSchema: 'multiple-choice' (Felder: front + answer, distractor_pool optional)
- subIndexCount: 'multiple-choice' → 1
- GET /api/v1/decks/:deckId/distractors: N zufällige Feldwerte anderer Karten
  im Deck; field-Allowlist (front/back/answer/question); RANDOM() ORDER; Fallback
  auf distractor_pool wenn Deck < 4 Karten
- fetchDistractors(): Frontend-Client-Funktion
- MultipleChoiceView.svelte: lädt Distractors on mount, shuffelt 4 Optionen,
  zeigt Sofort-Feedback (correct/wrong/neutral), Keyboard 1–4 + Space;
  auto-grade correct→good, wrong→again
- Study-Page: isMultipleChoice + multipleChoiceData derived, Action-Bar
  ausgeblendet, onKey delegiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 15:28:37 +02:00
parent 0791436107
commit 2b36990e43
6 changed files with 351 additions and 4 deletions

View file

@ -1,10 +1,11 @@
import { and, eq, isNotNull } from 'drizzle-orm';
import { and, eq, isNotNull, ne } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
import { Hono } from 'hono';
import { DeckCreateSchema, DeckUpdateSchema } from '@cards/domain';
import { getDb, type CardsDb } from '../db/connection.ts';
import { decks } from '../db/schema/index.ts';
import { cards, decks } from '../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { ulid } from '../lib/ulid.ts';
@ -114,6 +115,51 @@ export function decksRouter(deps: DecksDeps = {}): Hono<{ Variables: AuthVars }>
return c.json({ deleted: id });
});
/**
* Liefert N zufällige Feldwerte aus anderen Karten desselben Decks
* als Distractors für multiple-choice-Karten.
* `field` muss in der Allowlist sein (kein freier SQL-Zugriff).
*/
r.get('/:deckId/distractors', async (c) => {
const userId = c.get('userId');
const deckId = c.req.param('deckId');
const cardId = c.req.query('cardId') ?? '';
const countRaw = parseInt(c.req.query('count') ?? '3', 10);
const count = isNaN(countRaw) ? 3 : Math.min(10, Math.max(1, countRaw));
const fieldParam = c.req.query('field') ?? 'back';
const ALLOWED_FIELDS = new Set(['front', 'back', 'answer', 'question']);
if (!ALLOWED_FIELDS.has(fieldParam)) {
return c.json({ error: 'invalid_field' }, 422);
}
const [deck] = await dbOf()
.select({ id: decks.id })
.from(decks)
.where(and(eq(decks.id, deckId), eq(decks.userId, userId)))
.limit(1);
if (!deck) return c.json({ error: 'deck_not_found' }, 404);
const where = cardId
? and(eq(cards.deckId, deckId), eq(cards.userId, userId), ne(cards.id, cardId))
: and(eq(cards.deckId, deckId), eq(cards.userId, userId));
const rows = await dbOf()
.select({
value: sql<string | null>`jsonb_extract_path_text(${cards.fields}, ${fieldParam})`,
})
.from(cards)
.where(where)
.orderBy(sql`RANDOM()`)
.limit(count);
const distractors = rows
.map((r) => r.value)
.filter((v): v is string => typeof v === 'string' && v.length > 0);
return c.json({ distractors });
});
return r;
}