From 03778e4bed793074ed7e3426c0c4850d36d75ea7 Mon Sep 17 00:00:00 2001 From: hafroese Date: Thu, 2 Apr 2026 23:39:09 +0200 Subject: [PATCH] feat: dashboard + tutorials modules - stats tiles, tutorial list, reader, completion --- edu/api/dashboard.php | 40 +++++++++++++++++++++++++++++ edu/api/tutorials.php | 47 +++++++++++++++++++++++++++++++++ edu/js/dashboard.js | 50 ++++++++++++++++++++++++++++++++++++ edu/js/tutorials.js | 60 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 edu/api/dashboard.php create mode 100644 edu/api/tutorials.php create mode 100644 edu/js/dashboard.js create mode 100644 edu/js/tutorials.js diff --git a/edu/api/dashboard.php b/edu/api/dashboard.php new file mode 100644 index 0000000..1756350 --- /dev/null +++ b/edu/api/dashboard.php @@ -0,0 +1,40 @@ +prepare("SELECT COUNT(*) FROM card_progress WHERE user_id = :uid AND last_reviewed_at IS NOT NULL"); +$stmt->execute([':uid' => $user_id]); +$cards_learned = (int) $stmt->fetchColumn(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM card_progress WHERE user_id = :uid AND next_review <= CURRENT_DATE"); +$stmt->execute([':uid' => $user_id]); +$cards_due = (int) $stmt->fetchColumn(); + +$stmt = $pdo->prepare(" + SELECT COUNT(*) FILTER (WHERE last_rating IN ('easy','medium')) AS correct, COUNT(*) AS total + FROM card_progress WHERE user_id = :uid AND last_reviewed_at IS NOT NULL +"); +$stmt->execute([':uid' => $user_id]); +$acc = $stmt->fetch(); +$correct_pct = ($acc['total'] > 0) ? round(($acc['correct'] / $acc['total']) * 100) : 0; + +$total_cards = (int) $pdo->query("SELECT COUNT(*) FROM cards")->fetchColumn(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM tutorial_progress WHERE user_id = :uid AND completed = true"); +$stmt->execute([':uid' => $user_id]); +$tutorials_done = (int) $stmt->fetchColumn(); +$tutorials_total = (int) $pdo->query("SELECT COUNT(*) FROM tutorials")->fetchColumn(); + +$deck_count = (int) $pdo->query("SELECT COUNT(*) FROM decks")->fetchColumn(); +$cheatsheet_count = (int) $pdo->query("SELECT COUNT(*) FROM cheatsheets")->fetchColumn(); + +$stmt = $pdo->prepare("SELECT score_percent FROM quiz_results WHERE user_id = :uid ORDER BY created_at DESC LIMIT 1"); +$stmt->execute([':uid' => $user_id]); +$last_quiz = $stmt->fetchColumn(); + +json_ok([ + 'cards_learned' => $cards_learned, 'cards_due' => $cards_due, 'cards_total' => $total_cards, + 'correct_pct' => $correct_pct, 'tutorials_done' => $tutorials_done, 'tutorials_total' => $tutorials_total, + 'deck_count' => $deck_count, 'cheatsheet_count' => $cheatsheet_count, + 'last_quiz_score' => $last_quiz !== false ? (int) $last_quiz : null +]); diff --git a/edu/api/tutorials.php b/edu/api/tutorials.php new file mode 100644 index 0000000..e86b4a3 --- /dev/null +++ b/edu/api/tutorials.php @@ -0,0 +1,47 @@ +prepare(" + SELECT t.id, t.slug, t.title, t.description, t.duration_min, t.sort_order, + COALESCE(tp.completed, false) AS completed, tp.completed_at + FROM tutorials t + LEFT JOIN tutorial_progress tp ON tp.tutorial_id = t.id AND tp.user_id = :uid + ORDER BY t.sort_order + "); + $stmt->execute([':uid' => $user_id]); + json_ok(['tutorials' => $stmt->fetchAll()]); +} + +// GET /api/tutorials/{slug} +if (get_method() === 'GET' && !empty($segments[1]) && ($segments[2] ?? '') !== 'complete') { + $stmt = $pdo->prepare(" + SELECT t.*, COALESCE(tp.completed, false) AS completed + FROM tutorials t + LEFT JOIN tutorial_progress tp ON tp.tutorial_id = t.id AND tp.user_id = :uid + WHERE t.slug = :slug + "); + $stmt->execute([':slug' => $segments[1], ':uid' => $user_id]); + $tutorial = $stmt->fetch(); + if (!$tutorial) json_error('Tutorial nicht gefunden', 404); + json_ok(['tutorial' => $tutorial]); +} + +// POST /api/tutorials/{slug}/complete +if (get_method() === 'POST' && !empty($segments[1]) && ($segments[2] ?? '') === 'complete') { + $stmt = $pdo->prepare("SELECT id FROM tutorials WHERE slug = :slug"); + $stmt->execute([':slug' => $segments[1]]); + $tutorial = $stmt->fetch(); + if (!$tutorial) json_error('Tutorial nicht gefunden', 404); + + $stmt = $pdo->prepare(" + INSERT INTO tutorial_progress (user_id, tutorial_id, completed, completed_at) + VALUES (:uid, :tid, true, NOW()) + ON CONFLICT (user_id, tutorial_id) DO UPDATE SET completed = true, completed_at = NOW() + "); + $stmt->execute([':uid' => $user_id, ':tid' => $tutorial['id']]); + json_ok(['ok' => true]); +} + +json_error('Unbekannter Tutorials-Endpunkt', 404); diff --git a/edu/js/dashboard.js b/edu/js/dashboard.js new file mode 100644 index 0000000..a8d63eb --- /dev/null +++ b/edu/js/dashboard.js @@ -0,0 +1,50 @@ +'use strict'; +// NOTE: innerHTML safe - only own API data (numbers, names) + +var Dashboard = { + render: function() { + var el = document.getElementById('content'); + el.textContent = 'Lade Dashboard...'; + + API.get('/dashboard').then(function(d) { + var cardPct = d.cards_total > 0 ? Math.round((d.cards_learned / d.cards_total) * 100) : 0; + var tutPct = d.tutorials_total > 0 ? Math.round((d.tutorials_done / d.tutorials_total) * 100) : 0; + var dueText = d.cards_due > 0 + ? '' + d.cards_due + ' Karten faellig heute' + : 'Keine faelligen Karten — gut gemacht!'; + + var h = ''; + h += '
'; + h += '
📚
'; + h += '
Flashcards
'; + h += '
' + d.cards_total + ' Karten in ' + d.deck_count + ' Decks
'; + h += '
'; + h += '
' + cardPct + '% gelernt'; + if (d.cards_due > 0) h += ' — ' + d.cards_due + ' faellig'; + h += '
'; + h += '
📖
'; + h += '
Tutorials
'; + h += '
' + d.tutorials_total + ' Lektionen
'; + h += '
'; + h += '
' + d.tutorials_done + '/' + d.tutorials_total + ' abgeschlossen
'; + h += '
'; + h += '
Quiz
Wissen testen
'; + if (d.last_quiz_score !== null) { + h += '
Letzte Note: ' + d.last_quiz_score + '%
'; + } else { + h += '
Noch kein Quiz
'; + } + h += '
'; + h += '
📄
'; + h += '
Cheat Sheets
'; + h += '
' + d.cheatsheet_count + ' Referenzkarten
'; + h += '
Schnellreferenz
'; + h += '
'; + el.innerHTML = h; + el.querySelectorAll('.module-tile').forEach(function(tile) { + tile.addEventListener('click', function() { location.hash = tile.dataset.href; }); + }); + }).catch(function(e) { el.textContent = e.message; }); + } +}; diff --git a/edu/js/tutorials.js b/edu/js/tutorials.js new file mode 100644 index 0000000..11ae7e4 --- /dev/null +++ b/edu/js/tutorials.js @@ -0,0 +1,60 @@ +'use strict'; + +var Tutorials = { + render: function(params) { + var el = document.getElementById('content'); + if (params[0]) { this.renderSingle(el, params[0]); return; } + el.textContent = 'Lade Tutorials...'; + var self = this; + API.get('/tutorials').then(function(data) { self.renderList(el, data.tutorials); }) + .catch(function(e) { el.textContent = e.message; }); + }, + + renderList: function(el, tutorials) { + var done = tutorials.filter(function(t) { return t.completed; }).length; + var h = ''; + h += '
'; + tutorials.forEach(function(t, i) { + h += '
'; + h += '
' + String(i + 1).padStart(2, '0') + '
'; + h += '
' + Markdown.esc(t.title) + '
'; + h += '
' + (t.duration_min ? 'ca. ' + t.duration_min + ' Min' : ''); + if (t.description) h += ' — ' + Markdown.esc(t.description); + h += '
'; + if (t.completed) h += '
'; + h += '
'; + }); + h += '
'; + el.innerHTML = h; + el.querySelectorAll('.tutorial-item').forEach(function(item) { + item.addEventListener('click', function() { location.hash = '#/tutorials/' + item.dataset.slug; }); + }); + }, + + renderSingle: function(el, slug) { + el.textContent = 'Lade Tutorial...'; + var self = this; + API.get('/tutorials/' + slug).then(function(data) { + var t = data.tutorial; + var h = '← Zurueck zu Tutorials'; + h += '
' + Markdown.render(t.content_md) + '

'; + h += '
'; + if (t.completed) { + h += '

✓ Abgeschlossen

'; + } else { + h += ''; + } + h += '
'; + el.innerHTML = h; + var btn = document.getElementById('complete-btn'); + if (btn) btn.addEventListener('click', function() { self.complete(slug); }); + }).catch(function(e) { el.textContent = e.message; }); + }, + + complete: function(slug) { + var self = this; + API.post('/tutorials/' + slug + '/complete').then(function() { + self.renderSingle(document.getElementById('content'), slug); + }).catch(function(e) { alert('Fehler: ' + e.message); }); + } +};