feat(web): multiple-choice — explanation-Feld, Edit-Bug-Fix, State-Reset

- MultipleChoiceCardForm: optionales `explanation`-Feld (Erklärung wird
  nach Auswahl angezeigt); `field-optional`-Style ergänzt
- MultipleChoiceView: `explanation`-Prop; zeigt Erklärungsbox nach
  Auswahl (grün bei richtig, neutral bei falsch); `{#key card_id}`-Block
  erzwingt Remount bei Kartenwechsel — behebt State-Leak zwischen Karten
- edit/+page.svelte: MC-Edit-Bug behoben — Karten wurden fälschlich mit
  `{front, back}` gespeichert und haben `answer`/`distractor_pool`
  überschrieben; `MultipleChoiceCardForm` importiert und verdrahtet;
  `canSave` und `onSubmit` handhaben MC korrekt; lädt `answer` +
  `distractor_pool` beim Öffnen zurück in `mcOptions`-Array
- new/+page.svelte: `mcExplanation`-State an Form gebunden und beim
  Speichern als `fields.explanation` gesetzt
- study/+page.svelte: `explanation` aus Card-Fields extrahiert und
  an MultipleChoiceView durchgereicht
- scripts/migrate-factfulness-to-mc.ts: einmalige Migration — 13
  Factfulness-Quiz-Karten von `basic` (A/B/C in Freitext) auf
  `multiple-choice` mit strukturierten Feldern konvertiert; Deck auf
  `visibility=public` gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-11 18:24:18 +02:00
parent 41ecec16c3
commit 9839737049
6 changed files with 209 additions and 6 deletions

View file

@ -0,0 +1,116 @@
/**
* Migrates the 13 Factfulness quiz cards from type=basic (A/B/C embedded in
* front text) to type=multiple-choice with proper field structure.
* Also sets the deck to visibility=public.
*
* Run from repo root: bun --cwd apps/api run ../../scripts/migrate-factfulness-to-mc.ts
*/
import postgres from 'postgres';
const DECK_ID = '01KR96NH892HBP1TV5M3XJTB39';
const DB_URL = 'postgresql://cards:cards@localhost:5435/cards';
const sql = postgres(DB_URL);
interface CardRow {
id: string;
fields: { front?: string; back?: string };
}
function parseQuizCard(front: string, back: string): {
question: string;
answer: string;
distractorPool: string;
explanation: string;
} | null {
// front: "<question>\n\nA: opt1\nB: opt2\nC: opt3"
const splitIdx = front.indexOf('\n\nA:');
if (splitIdx === -1) return null;
const question = front.slice(0, splitIdx).trim();
const optionsBlock = front.slice(splitIdx + 2).trim(); // starts with "A: …"
const options: Record<string, string> = {};
for (const line of optionsBlock.split('\n')) {
const m = line.match(/^([A-D]):\s*(.+)$/);
if (m) options[m[1]] = m[2].trim();
}
// back: "LETTER — answer_text\n\nexplanation"
const backMatch = back.match(/^([A-D])\s*[—–-]+\s*(.+?)(?:\n\n([\s\S]*))?$/);
if (!backMatch) return null;
const correctLetter = backMatch[1];
const answer = options[correctLetter] ?? backMatch[2].trim();
const explanation = (backMatch[3] ?? '').trim();
const distractorPool = Object.entries(options)
.filter(([l]) => l !== correctLetter)
.map(([, v]) => v)
.join('\n');
return { question, answer, distractorPool, explanation };
}
async function run() {
console.log('Fetching basic cards from Factfulness deck…\n');
const rows = await sql<CardRow[]>`
SELECT id, fields
FROM cards.cards
WHERE deck_id = ${DECK_ID}
AND type = 'basic'
`;
let migrated = 0;
let skipped = 0;
for (const row of rows) {
const front = row.fields.front ?? '';
const back = row.fields.back ?? '';
if (!front.includes('\n\nA:')) {
skipped++;
continue;
}
const parsed = parseQuizCard(front, back);
if (!parsed) {
console.warn(` ⚠ Cannot parse ${row.id.slice(-8)}: "${front.slice(0, 60)}"`);
skipped++;
continue;
}
const { question, answer, distractorPool, explanation } = parsed;
const newFields: Record<string, string> = { front: question, answer };
if (distractorPool) newFields.distractor_pool = distractorPool;
if (explanation) newFields.explanation = explanation;
await sql`
UPDATE cards.cards
SET type = 'multiple-choice',
fields = ${sql.json(newFields)}
WHERE id = ${row.id}
`;
console.log(`${row.id.slice(-8)} Q: "${question.slice(0, 55)}…"`);
migrated++;
}
await sql`
UPDATE cards.decks
SET visibility = 'public'
WHERE id = ${DECK_ID}
`;
console.log(`\n ✓ Deck → visibility=public`);
console.log(`\nDone: ${migrated} migrated, ${skipped} basic cards kept as-is.`);
await sql.end();
}
run().catch((e) => {
console.error(e);
process.exit(1);
});