feat: flashcards module - deck list, study mode, SM-2 review, session summary

Dieser Commit ist enthalten in:
hafroese 2026-04-02 23:11:07 +02:00
Ursprung 6a504254b0
Commit 2c34aea901
3 geänderte Dateien mit 279 neuen und 0 gelöschten Zeilen

92
edu/api/cards.php Normale Datei
Datei anzeigen

@ -0,0 +1,92 @@
<?php
require_once __DIR__ . '/sm2.php';
$user_id = require_auth();
// GET /api/cards/deck/{slug}
if (get_method() === 'GET' && ($segments[1] ?? '') === 'deck' && !empty($segments[2])) {
$slug = $segments[2];
$stmt = $pdo->prepare("
SELECT c.id, c.question, c.answer, c.level, c.sort_order,
cp.next_review, cp.last_rating, cp.last_reviewed_at,
cp.ease_factor, cp.interval_days, cp.repetitions,
d.name AS deck_name, d.slug AS deck_slug
FROM cards c
JOIN decks d ON d.id = c.deck_id AND d.slug = :slug
LEFT JOIN card_progress cp ON cp.card_id = c.id AND cp.user_id = :uid
ORDER BY c.sort_order
");
$stmt->execute([':slug' => $slug, ':uid' => $user_id]);
json_ok(['cards' => $stmt->fetchAll()]);
}
// GET /api/cards/due?deck={slug}&limit=N
if (get_method() === 'GET' && ($segments[1] ?? '') === 'due') {
$slug = get_param('deck', '');
$limit = max(1, min(50, (int) get_param('limit', 20)));
$query = "
SELECT c.id, c.question, c.answer, c.level, c.sort_order, c.deck_id,
d.slug AS deck_slug, d.name AS deck_name,
cp.next_review, cp.last_rating, cp.last_reviewed_at,
cp.ease_factor, cp.interval_days, cp.repetitions
FROM cards c
JOIN decks d ON d.id = c.deck_id
LEFT JOIN card_progress cp ON cp.card_id = c.id AND cp.user_id = :uid
WHERE (cp.next_review IS NULL OR cp.next_review <= CURRENT_DATE)
";
$params = [':uid' => $user_id];
if ($slug) {
$query .= " AND d.slug = :slug";
$params[':slug'] = $slug;
}
$query .= " ORDER BY cp.next_review ASC NULLS FIRST, c.sort_order LIMIT " . $limit;
$stmt = $pdo->prepare($query);
$stmt->execute($params);
json_ok(['cards' => $stmt->fetchAll()]);
}
// POST /api/cards/{id}/review
if (get_method() === 'POST' && is_numeric($segments[1] ?? '') && ($segments[2] ?? '') === 'review') {
$card_id = (int) $segments[1];
$body = get_json_body();
$rating = $body['rating'] ?? '';
if (!in_array($rating, ['easy', 'medium', 'hard', 'wrong'])) {
json_error('Ungueltige Bewertung. Erlaubt: easy, medium, hard, wrong');
}
$stmt = $pdo->prepare("SELECT id FROM cards WHERE id = :id");
$stmt->execute([':id' => $card_id]);
if (!$stmt->fetch()) json_error('Karte nicht gefunden', 404);
$stmt = $pdo->prepare("SELECT * FROM card_progress WHERE user_id = :uid AND card_id = :cid");
$stmt->execute([':uid' => $user_id, ':cid' => $card_id]);
$progress = $stmt->fetch();
$ease = $progress['ease_factor'] ?? 2.5;
$interval = $progress['interval_days'] ?? 0;
$reps = $progress['repetitions'] ?? 0;
$result = sm2_calculate($rating, $ease, $interval, $reps);
$stmt = $pdo->prepare("
INSERT INTO card_progress (user_id, card_id, ease_factor, interval_days, repetitions, next_review, last_rating, last_reviewed_at)
VALUES (:uid, :cid, :ease, :intv, :reps, :next, :rating, NOW())
ON CONFLICT (user_id, card_id) DO UPDATE SET
ease_factor = :ease, interval_days = :intv, repetitions = :reps,
next_review = :next, last_rating = :rating, last_reviewed_at = NOW()
");
$stmt->execute([
':uid' => $user_id, ':cid' => $card_id,
':ease' => $result['ease_factor'], ':intv' => $result['interval_days'],
':reps' => $result['repetitions'], ':next' => $result['next_review'],
':rating' => $rating
]);
json_ok(['progress' => $result]);
}
json_error('Unbekannter Cards-Endpunkt', 404);

30
edu/api/decks.php Normale Datei
Datei anzeigen

@ -0,0 +1,30 @@
<?php
$user_id = require_auth();
// GET /api/decks
if (get_method() === 'GET' && empty($segments[1])) {
$stmt = $pdo->prepare("
SELECT d.id, d.slug, d.name, d.description, d.card_count,
COUNT(cp.id) AS reviewed,
COUNT(cp.id) FILTER (WHERE cp.last_rating IN ('easy','medium')) AS correct,
COUNT(cp.id) FILTER (WHERE cp.next_review <= CURRENT_DATE) AS due
FROM decks d
LEFT JOIN cards c ON c.deck_id = d.id
LEFT JOIN card_progress cp ON cp.card_id = c.id AND cp.user_id = :uid
GROUP BY d.id
ORDER BY d.sort_order, d.name
");
$stmt->execute([':uid' => $user_id]);
json_ok(['decks' => $stmt->fetchAll()]);
}
// GET /api/decks/{slug}
if (get_method() === 'GET' && !empty($segments[1])) {
$stmt = $pdo->prepare("SELECT * FROM decks WHERE slug = :s");
$stmt->execute([':s' => $segments[1]]);
$deck = $stmt->fetch();
if (!$deck) json_error('Deck nicht gefunden', 404);
json_ok(['deck' => $deck]);
}
json_error('Methode nicht erlaubt', 405);

157
edu/js/flashcards.js Normale Datei
Datei anzeigen

@ -0,0 +1,157 @@
'use strict';
// NOTE: innerHTML usage is intentional - content comes from our own API/DB only (admin-imported Markdown).
// This is an internal training tool for 2-5 known users. No user-generated content.
var Flashcards = {
session: null,
render: function(params) {
var el = document.getElementById('content');
if (params[0] === 'study' && params[1]) {
this.renderStudy(el, params[1]);
return;
}
el.textContent = 'Lade Decks...';
var self = this;
API.get('/decks').then(function(data) {
self.renderDeckList(el, data.decks);
}).catch(function(e) {
el.textContent = e.message;
});
},
renderDeckList: function(el, decks) {
var total = decks.reduce(function(s, d) { return s + (d.card_count || 0); }, 0);
var totalDue = decks.reduce(function(s, d) { return s + (parseInt(d.due) || 0); }, 0);
var h = '<div class="page-header"><h1>Flashcards</h1>';
h += '<p>' + total + ' Karten in ' + decks.length + ' Decks';
if (totalDue > 0) h += ' &mdash; <strong style="color:var(--orange)">' + totalDue + ' faellig</strong>';
h += '</p></div><div class="deck-grid">';
decks.forEach(function(d) {
var reviewed = parseInt(d.reviewed) || 0;
var ct = d.card_count || 0;
var due = parseInt(d.due) || 0;
var pct = ct > 0 ? Math.round((reviewed / ct) * 100) : 0;
h += '<div class="deck-card" data-slug="' + d.slug + '">';
h += '<div class="deck-name">' + Markdown.esc(d.name) + '</div>';
h += '<div class="deck-info">' + ct + ' Karten &mdash; ' + pct + '% gelernt</div>';
if (due > 0) h += '<div class="deck-due">' + due + ' Karten faellig</div>';
h += '<div class="progress-bar"><div class="fill fill-green" style="width:' + pct + '%"></div></div>';
h += '</div>';
});
h += '</div>';
el.innerHTML = h; // safe: data from own API only
el.querySelectorAll('.deck-card').forEach(function(card) {
card.addEventListener('click', function() {
location.hash = '#/flashcards/study/' + card.dataset.slug;
});
});
},
renderStudy: function(el, slug) {
el.textContent = 'Lade Karten...';
var self = this;
API.get('/cards/due?deck=' + slug + '&limit=50').then(function(data) {
if (data.cards.length === 0) {
return API.get('/cards/deck/' + slug).then(function(allData) {
var h = '<a href="#/flashcards" class="back-link">&larr; Zurueck zu Decks</a>';
h += '<div class="empty-state"><h2>Keine faelligen Karten</h2>';
h += '<p>Alle Karten sind gelernt. Komm spaeter wieder!</p><br>';
h += '<button class="btn btn-primary" id="start-all-btn">Alle ' + allData.cards.length + ' Karten ueben</button></div>';
el.innerHTML = h; // safe: static text + number
document.getElementById('start-all-btn').addEventListener('click', function() {
self.startSession(el, slug, allData.cards);
});
});
}
self.startSession(el, slug, data.cards);
}).catch(function(e) { el.textContent = e.message; });
},
startSession: function(el, slug, cards) {
this.session = { slug: slug, cards: cards, index: 0, correct: 0, wrong: 0, showAnswer: false };
this.renderCard(el);
},
renderCard: function(el) {
var s = this.session;
if (s.index >= s.cards.length) { this.renderSummary(el); return; }
var card = s.cards[s.index];
var open = s.cards.length - s.index - 1;
var deckName = card.deck_name || s.slug;
var lastReview = card.last_reviewed_at
? 'Zuletzt: ' + new Date(card.last_reviewed_at).toLocaleDateString('de-DE')
: 'Noch nie gelernt';
var h = '<a href="#/flashcards" class="back-link">&larr; Zurueck zu Decks</a>';
h += '<div class="study-header"><span class="study-deck-name">' + Markdown.esc(deckName) + '</span>';
h += '<div class="study-stats"><span class="stat-correct">' + s.correct + ' richtig</span>';
h += '<span class="stat-wrong">' + s.wrong + ' falsch</span>';
h += '<span class="stat-open">' + open + ' offen</span></div>';
h += '<span class="study-counter">Karte ' + (s.index + 1) + ' / ' + s.cards.length + '</span></div>';
h += '<div class="progress-segmented"><div class="seg-correct" style="flex:' + s.correct + '"></div>';
h += '<div class="seg-wrong" style="flex:' + s.wrong + '"></div>';
h += '<div class="seg-open" style="flex:' + (open + 1) + '"></div></div><br>';
h += '<div class="flashcard"><div class="card-meta"><span class="card-level">' + (card.level || 'basis') + '</span>';
h += '<span class="card-last-review">' + lastReview + '</span></div>';
h += '<div class="card-question">' + Markdown.render(card.question) + '</div>';
if (s.showAnswer) {
h += '<div class="card-answer"><div class="card-answer-label">Antwort</div>' + Markdown.render(card.answer) + '</div>';
h += '<div class="rating-buttons">';
h += '<button class="btn btn-green" id="rate-easy">Leicht</button>';
h += '<button class="btn btn-orange" id="rate-medium">Mittel</button>';
h += '<button class="btn btn-red" id="rate-hard">Schwer</button>';
h += '<button class="btn btn-muted" id="rate-wrong">Falsch</button></div>';
} else {
h += '<div class="card-answer-hidden"><button class="btn btn-primary" id="show-answer-btn">Antwort zeigen</button></div>';
}
h += '</div>';
el.innerHTML = h; // safe: Markdown content from own DB
var self = this;
if (s.showAnswer) {
document.getElementById('rate-easy').addEventListener('click', function() { self.rate('easy'); });
document.getElementById('rate-medium').addEventListener('click', function() { self.rate('medium'); });
document.getElementById('rate-hard').addEventListener('click', function() { self.rate('hard'); });
document.getElementById('rate-wrong').addEventListener('click', function() { self.rate('wrong'); });
} else {
document.getElementById('show-answer-btn').addEventListener('click', function() {
s.showAnswer = true;
self.renderCard(el);
});
}
},
rate: function(rating) {
var s = this.session;
var card = s.cards[s.index];
if (rating === 'easy' || rating === 'medium') s.correct++;
else s.wrong++;
API.post('/cards/' + card.id + '/review', { rating: rating }).catch(function(e) { console.error('Review error:', e); });
s.index++;
s.showAnswer = false;
this.renderCard(document.getElementById('content'));
},
renderSummary: function(el) {
var s = this.session;
var total = s.correct + s.wrong;
var pct = total > 0 ? Math.round((s.correct / total) * 100) : 0;
var h = '<a href="#/flashcards" class="back-link">&larr; Zurueck zu Decks</a>';
h += '<div style="text-align:center;padding:48px 0;"><h2>Session abgeschlossen!</h2><br>';
h += '<div class="study-stats" style="justify-content:center;font-size:18px;">';
h += '<span class="stat-correct">' + s.correct + ' richtig</span>';
h += '<span class="stat-wrong">' + s.wrong + ' falsch</span></div><br>';
h += '<p style="color:var(--text-secondary);font-size:18px;">' + pct + '% korrekt</p><br>';
h += '<a href="#/flashcards" class="btn btn-primary">Zurueck zu Decks</a></div>';
el.innerHTML = h; // safe: only session stats
}
};