From 2c34aea9011a8088c72d392a1b86627dffc92369 Mon Sep 17 00:00:00 2001 From: hafroese Date: Thu, 2 Apr 2026 23:11:07 +0200 Subject: [PATCH] feat: flashcards module - deck list, study mode, SM-2 review, session summary --- edu/api/cards.php | 92 +++++++++++++++++++++++++ edu/api/decks.php | 30 +++++++++ edu/js/flashcards.js | 157 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 edu/api/cards.php create mode 100644 edu/api/decks.php create mode 100644 edu/js/flashcards.js diff --git a/edu/api/cards.php b/edu/api/cards.php new file mode 100644 index 0000000..1e4b729 --- /dev/null +++ b/edu/api/cards.php @@ -0,0 +1,92 @@ +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); diff --git a/edu/api/decks.php b/edu/api/decks.php new file mode 100644 index 0000000..329b8fe --- /dev/null +++ b/edu/api/decks.php @@ -0,0 +1,30 @@ +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); diff --git a/edu/js/flashcards.js b/edu/js/flashcards.js new file mode 100644 index 0000000..4701b75 --- /dev/null +++ b/edu/js/flashcards.js @@ -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 = '
'; + + 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 += '
'; + h += '
' + Markdown.esc(d.name) + '
'; + h += '
' + ct + ' Karten — ' + pct + '% gelernt
'; + if (due > 0) h += '
' + due + ' Karten faellig
'; + h += '
'; + h += '
'; + }); + + h += '
'; + 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 = '← Zurueck zu Decks'; + h += '

Keine faelligen Karten

'; + h += '

Alle Karten sind gelernt. Komm spaeter wieder!


'; + h += '
'; + 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 = '← Zurueck zu Decks'; + h += '
' + Markdown.esc(deckName) + ''; + h += '
' + s.correct + ' richtig'; + h += '' + s.wrong + ' falsch'; + h += '' + open + ' offen
'; + h += 'Karte ' + (s.index + 1) + ' / ' + s.cards.length + '
'; + h += '
'; + h += '
'; + h += '

'; + h += '
' + (card.level || 'basis') + ''; + h += '' + lastReview + '
'; + h += '
' + Markdown.render(card.question) + '
'; + + if (s.showAnswer) { + h += '
Antwort
' + Markdown.render(card.answer) + '
'; + h += '
'; + h += ''; + h += ''; + h += ''; + h += '
'; + } else { + h += '
'; + } + h += '
'; + 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 = '← Zurueck zu Decks'; + h += '

Session abgeschlossen!


'; + h += '
'; + h += '' + s.correct + ' richtig'; + h += '' + s.wrong + ' falsch

'; + h += '

' + pct + '% korrekt


'; + h += 'Zurueck zu Decks
'; + el.innerHTML = h; // safe: only session stats + } +};