LAPractice: Karteikarten-Trainer für die LAP-Theoriefragen

Web-App zum Üben der Repetitionsfragen: Fragensets werden als JSON
importiert, Fragen und Antwort-Statistik liegen in SQLite.

Ablauf beim Üben ist selbstkontrolliert – Frage sehen, im Kopf oder dem
Partner antworten, Karte aufdecken, RICHTIG/FALSCH bewerten.

- Server ohne Abhängigkeiten (node:http, node:sqlite, Node 22.5+)
- Import führt Sets mit gleichem Titel zusammen: geänderte Antworten
  werden aktualisiert, fehlende Fragen archiviert, die Statistik bleibt
- Auswahl nach Kategorien/Unterkategorien, Filter auf falsch beantwortete
  oder noch nie gesehene Fragen
- Reihenfolge frei kombinierbar: mischen und/oder Schwächen zuerst
- Endlos-Modus wiederholt, bis die Runde beendet wird; falsche Karten
  kommen in derselben Runde erneut
- Tastatursteuerung, Rundenauswertung und Statistik mit Kategorie-Quoten,
  schwächsten Fragen und 14-Tage-Verlauf

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-08-28 10:05:47 +02:00
commit e5c4842784
9 changed files with 3925 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
data/*.db
data/*.db-journal
data/*.db-wal
data/*.db-shm

99
README.md Normal file
View File

@ -0,0 +1,99 @@
# LAPractice
Karteikarten-Trainer für die LAP-Theoriefragen. Fragen kommen als JSON herein,
Fragen und Antwort-Statistik liegen danach in einer SQLite-Datei.
Der Ablauf beim Üben ist bewusst selbstkontrolliert:
**Frage sehen → im Kopf (oder dem Partner) beantworten → Karte aufdecken → RICHTIG / FALSCH.**
## Starten
```sh
npm start # http://127.0.0.1:4173
npm run dev # dasselbe, startet bei Dateiänderungen neu
```
Keine Abhängigkeiten der Server nutzt nur die Node-Standardbibliothek
(`node:http`, `node:sqlite`). Voraussetzung ist **Node 22.5+**.
Konfiguration über Umgebungsvariablen: `PORT`, `HOST`, `LAP_DB` (Pfad zur
Datenbank, Standard `data/lapractice.db`).
## Fragenset-Format
```jsonc
{
"title": "Arbeitsunterlagen und Repetitionsfragen zur LAP …",
"categories": [
{
"id": 1, // optional
"title": "Netzwerktechnik",
"subcategories": [
{
"name": "Grundlagen",
"questions": [
{ "q": "Erklären Sie den Begriff TP-Kabel.", "a": "Twisted-Pair-Kabel …" }
]
}
]
}
]
}
```
Toleranzen beim Import: `name` statt `title`, `question`/`answer` statt `q`/`a`,
und eine Kategorie darf ihre `questions` auch direkt (ohne `subcategories`)
tragen. Fragen ohne Text oder ohne Antwort werden übersprungen.
**Erneuter Import:** Ein Set mit gleichem `title` wird zusammengeführt statt
dupliziert Fragen werden über Kategorie + Unterkategorie + Fragetext
wiedererkannt, geänderte Antworten aktualisiert, neue ergänzt und die
Statistik bleibt erhalten. Fragen, die in der neuen Datei fehlen, werden
archiviert (nicht gelöscht) und tauchen beim Üben nicht mehr auf.
## Üben
* **Auswahl:** Kategorien und Unterkategorien einzeln an-/abwählbar, die
Auswahl wird pro Fragenset im Browser gemerkt.
* **Fragen** (Filter): alle · nur zuletzt falsch beantwortete · nur noch nie
gesehene. Die Poolgröße im Startbalken zeigt, wie viele Karten übrig bleiben.
* **Fragen mischen:** an = zufällige Reihenfolge, aus = Reihenfolge des Skriptums.
* **Schwächen zuerst:** jede dritte Karte ist eine fällige Wiederholung, der Rest
neues Material. Lässt sich mit dem Mischen frei kombinieren aus/aus ergibt
exakt die Skriptum-Reihenfolge.
* **Endlos üben:** die Runde läuft, bis du sie selbst beendest (`Esc` oder
„Beenden"). Ist ein Stapel durch, wird der nächste mit dem aktuellen
Wissensstand geholt; falsch beantwortete Karten kommen zusätzlich noch in
derselben Runde wieder. Die Anzahl gilt dann als Karten *pro Runde*.
* **Tastatur:** `Leertaste` aufdecken · `1`/`←` falsch · `2`/`→` richtig ·
`S` überspringen (zählt nicht in die Statistik) · `Esc` Runde beenden.
* Nach der Runde: Zusammenfassung mit Trefferquote und allen Karten, dazu
„Falsche wiederholen" für einen direkten zweiten Durchgang.
Als *fällig* gilt eine Frage, deren letzte Antwort falsch war, deren
Trefferquote unter 70 % liegt oder die länger als 7 Tage nicht dran war.
## Datenmodell
```
sets ─┬─ categories ─┬─ subcategories ─┬─ questions ─┬─ reviews
│ │ │ │
title title name q, a, archived correct, created_at
```
`reviews` speichert jede einzelne Bewertung mit Zeitstempel; alle Kennzahlen
(Trefferquote, schwächste Fragen, Tagesverlauf) werden daraus berechnet.
Das Zurücksetzen des Fortschritts löscht nur `reviews`, nie die Fragen.
## API
| Methode | Pfad | Zweck |
| -------- | --------------------------- | --------------------------------------- |
| `GET` | `/api/sets` | Fragensets mit Fortschritt |
| `POST` | `/api/sets` | Import (`{ data, filename }`) |
| `GET` | `/api/sets/:id` | Kategoriebaum mit Zählern |
| `DELETE` | `/api/sets/:id` | Set samt Statistik löschen |
| `POST` | `/api/sets/:id/reset` | nur die Statistik löschen |
| `GET` | `/api/sets/:id/stats` | Auswertung |
| `POST` | `/api/sets/:id/session` | Runde zusammenstellen (`filter`, `shuffle`, `smart`, `limit`) |
| `POST` | `/api/reviews` | Bewertung speichern |

513
db.js Normal file
View File

@ -0,0 +1,513 @@
'use strict';
const { DatabaseSync } = require('node:sqlite');
const path = require('node:path');
const fs = require('node:fs');
const DATA_DIR = path.join(__dirname, 'data');
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new DatabaseSync(process.env.LAP_DB || path.join(DATA_DIR, 'lapractice.db'));
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS sets (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY,
set_id INTEGER NOT NULL REFERENCES sets(id) ON DELETE CASCADE,
ext_id INTEGER,
title TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
UNIQUE (set_id, title)
);
CREATE TABLE IF NOT EXISTS subcategories (
id INTEGER PRIMARY KEY,
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
UNIQUE (category_id, name)
);
CREATE TABLE IF NOT EXISTS questions (
id INTEGER PRIMARY KEY,
subcategory_id INTEGER NOT NULL REFERENCES subcategories(id) ON DELETE CASCADE,
q TEXT NOT NULL,
a TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE (subcategory_id, q)
);
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
correct INTEGER NOT NULL CHECK (correct IN (0, 1)),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reviews_question ON reviews(question_id);
CREATE INDEX IF NOT EXISTS idx_reviews_created ON reviews(created_at);
`);
const now = () => new Date().toISOString();
function tx(fn) {
db.exec('BEGIN');
try {
const result = fn();
db.exec('COMMIT');
return result;
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
}
/* ---------------------------------------------------------------- import -- */
/**
* Validates the shape of an imported question set and returns a normalised
* structure. Throws an Error with a human readable message on bad input.
*/
function normalizeSet(raw, fallbackTitle) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('Die Datei enthaelt kein JSON-Objekt.');
}
const title = String(raw.title || fallbackTitle || '').trim();
if (!title) throw new Error('Dem Fragenset fehlt ein "title".');
if (!Array.isArray(raw.categories) || raw.categories.length === 0) {
throw new Error('Dem Fragenset fehlt ein nicht-leeres "categories"-Array.');
}
const categories = raw.categories.map((cat, ci) => {
const catTitle = String(cat?.title ?? cat?.name ?? '').trim();
if (!catTitle) throw new Error(`Kategorie #${ci + 1} hat keinen "title".`);
// A category may either carry subcategories or questions directly; a flat
// category is modelled as a single subcategory with the same name.
const subsRaw = Array.isArray(cat.subcategories)
? cat.subcategories
: [{ name: catTitle, questions: cat.questions }];
const subcategories = subsRaw.map((sub, si) => {
const subName = String(sub?.name ?? sub?.title ?? '').trim() || catTitle;
const qsRaw = Array.isArray(sub?.questions) ? sub.questions : [];
const questions = [];
const seen = new Set();
for (const item of qsRaw) {
const q = String(item?.q ?? item?.question ?? '').trim();
const a = String(item?.a ?? item?.answer ?? '').trim();
if (!q || !a) continue;
if (seen.has(q)) continue; // duplicates within one subcategory
seen.add(q);
questions.push({ q, a });
}
return { name: subName, position: si, questions };
}).filter((sub) => sub.questions.length > 0);
return {
extId: Number.isFinite(cat.id) ? cat.id : ci + 1,
title: catTitle,
position: ci,
subcategories,
};
}).filter((cat) => cat.subcategories.length > 0);
const total = categories.reduce(
(sum, c) => sum + c.subcategories.reduce((s, sc) => s + sc.questions.length, 0), 0);
if (total === 0) throw new Error('Im Fragenset wurde keine einzige Frage gefunden.');
return { title, categories, total };
}
/**
* Imports (or re-imports) a set. Re-importing a set with the same title merges:
* questions are matched by category/subcategory/question text so that existing
* answer statistics survive. Questions missing from the new file are archived
* instead of deleted.
*/
function importSet(raw, fallbackTitle) {
const set = normalizeSet(raw, fallbackTitle);
const ts = now();
return tx(() => {
let row = db.prepare('SELECT id FROM sets WHERE title = ?').get(set.title);
let setId;
let isNew = false;
if (row) {
setId = row.id;
db.prepare('UPDATE sets SET updated_at = ? WHERE id = ?').run(ts, setId);
} else {
isNew = true;
setId = db.prepare('INSERT INTO sets (title, created_at, updated_at) VALUES (?, ?, ?)')
.run(set.title, ts, ts).lastInsertRowid;
}
const stats = { added: 0, updated: 0, unchanged: 0, archived: 0 };
const keptIds = new Set();
for (const cat of set.categories) {
let catRow = db.prepare('SELECT id FROM categories WHERE set_id = ? AND title = ?')
.get(setId, cat.title);
let catId;
if (catRow) {
catId = catRow.id;
db.prepare('UPDATE categories SET ext_id = ?, position = ? WHERE id = ?')
.run(cat.extId, cat.position, catId);
} else {
catId = db.prepare(
'INSERT INTO categories (set_id, ext_id, title, position) VALUES (?, ?, ?, ?)')
.run(setId, cat.extId, cat.title, cat.position).lastInsertRowid;
}
for (const sub of cat.subcategories) {
let subRow = db.prepare('SELECT id FROM subcategories WHERE category_id = ? AND name = ?')
.get(catId, sub.name);
let subId;
if (subRow) {
subId = subRow.id;
db.prepare('UPDATE subcategories SET position = ? WHERE id = ?').run(sub.position, subId);
} else {
subId = db.prepare(
'INSERT INTO subcategories (category_id, name, position) VALUES (?, ?, ?)')
.run(catId, sub.name, sub.position).lastInsertRowid;
}
sub.questions.forEach((item, qi) => {
const existing = db.prepare(
'SELECT id, a FROM questions WHERE subcategory_id = ? AND q = ?').get(subId, item.q);
if (existing) {
keptIds.add(existing.id);
if (existing.a !== item.a) stats.updated++; else stats.unchanged++;
db.prepare('UPDATE questions SET a = ?, position = ?, archived = 0 WHERE id = ?')
.run(item.a, qi, existing.id);
} else {
const id = db.prepare(
'INSERT INTO questions (subcategory_id, q, a, position, created_at) VALUES (?, ?, ?, ?, ?)')
.run(subId, item.q, item.a, qi, ts).lastInsertRowid;
keptIds.add(id);
stats.added++;
}
});
}
}
if (!isNew) {
const all = db.prepare(`
SELECT q.id FROM questions q
JOIN subcategories s ON s.id = q.subcategory_id
JOIN categories c ON c.id = s.category_id
WHERE c.set_id = ?`).all(setId);
const stale = all.map((r) => r.id).filter((id) => !keptIds.has(id));
const archive = db.prepare('UPDATE questions SET archived = 1 WHERE id = ?');
for (const id of stale) { archive.run(id); stats.archived++; }
}
return { setId, title: set.title, isNew, total: set.total, ...stats };
});
}
/* ----------------------------------------------------------------- reads -- */
const QUESTION_STATS = `
(SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id) AS reviews,
(SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id AND r.correct = 1) AS correct,
(SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id AND r.correct = 0) AS wrong,
(SELECT r.correct FROM reviews r WHERE r.question_id = q.id
ORDER BY r.id DESC LIMIT 1) AS last_correct,
(SELECT r.created_at FROM reviews r WHERE r.question_id = q.id
ORDER BY r.id DESC LIMIT 1) AS last_reviewed
`;
function listSets() {
return db.prepare(`
SELECT s.id, s.title, s.created_at, s.updated_at,
(SELECT COUNT(*) FROM questions q
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
WHERE c.set_id = s.id AND q.archived = 0) AS questions,
(SELECT COUNT(DISTINCT q.id) FROM questions q
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
JOIN reviews r ON r.question_id = q.id
WHERE c.set_id = s.id AND q.archived = 0) AS seen,
(SELECT COUNT(*) FROM reviews r
JOIN questions q ON q.id = r.question_id
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
WHERE c.set_id = s.id) AS answers,
(SELECT COUNT(*) FROM reviews r
JOIN questions q ON q.id = r.question_id
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
WHERE c.set_id = s.id AND r.correct = 1) AS answers_correct
FROM sets s
ORDER BY s.updated_at DESC`).all().map((r) => ({ ...r }));
}
function getSet(setId) {
const row = db.prepare('SELECT id, title, created_at, updated_at FROM sets WHERE id = ?').get(setId);
return row ? { ...row } : null;
}
/** Full category tree of a set including per-subcategory progress counters. */
function getTree(setId) {
const rows = db.prepare(`
SELECT c.id AS category_id, c.title AS category, c.position AS cpos,
sc.id AS subcategory_id, sc.name AS subcategory, sc.position AS spos,
COUNT(q.id) AS total,
SUM(CASE WHEN (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id) > 0
THEN 1 ELSE 0 END) AS seen,
SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id
ORDER BY r.id DESC LIMIT 1) = 1 THEN 1 ELSE 0 END) AS last_correct,
SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id
ORDER BY r.id DESC LIMIT 1) = 0 THEN 1 ELSE 0 END) AS last_wrong
FROM categories c
JOIN subcategories sc ON sc.category_id = c.id
JOIN questions q ON q.subcategory_id = sc.id AND q.archived = 0
WHERE c.set_id = ?
GROUP BY sc.id
ORDER BY c.position, c.id, sc.position, sc.id`).all(setId);
const byCategory = new Map();
for (const r of rows) {
if (!byCategory.has(r.category_id)) {
byCategory.set(r.category_id, {
id: r.category_id, title: r.category, total: 0, seen: 0,
lastCorrect: 0, lastWrong: 0, subcategories: [],
});
}
const cat = byCategory.get(r.category_id);
cat.subcategories.push({
id: r.subcategory_id, name: r.subcategory, total: r.total,
seen: r.seen, lastCorrect: r.last_correct, lastWrong: r.last_wrong,
});
cat.total += r.total;
cat.seen += r.seen;
cat.lastCorrect += r.last_correct;
cat.lastWrong += r.last_wrong;
}
return [...byCategory.values()];
}
const FILTERS = new Set(['all', 'wrong', 'unseen']);
/**
* Builds a practice session: takes the questions of the chosen subcategories,
* applies the filter, orders them (shuffled and/or weakest-first) and caps them
* at `limit`. Order and filter are independent, so every combination works.
*/
function buildSession({
setId, subcategoryIds = [], filter = 'all',
shuffle: doShuffle = true, smart = true, limit = 0,
}) {
if (!FILTERS.has(filter)) filter = 'all';
const params = [setId];
let scopeSql = '';
if (subcategoryIds.length) {
scopeSql = ` AND sc.id IN (${subcategoryIds.map(() => '?').join(',')})`;
params.push(...subcategoryIds);
}
const rows = db.prepare(`
SELECT q.id, q.q, q.a, q.position,
sc.id AS subcategory_id, sc.name AS subcategory,
c.id AS category_id, c.title AS category,
c.position AS cpos, sc.position AS spos,
${QUESTION_STATS}
FROM questions q
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
WHERE c.set_id = ? AND q.archived = 0${scopeSql}
ORDER BY c.position, c.id, sc.position, sc.id, q.position, q.id`).all(...params);
let pool = rows.map((r) => ({
id: r.id,
q: r.q,
a: r.a,
category: r.category,
subcategory: r.subcategory,
stats: {
reviews: r.reviews,
correct: r.correct,
wrong: r.wrong,
lastCorrect: r.last_correct === null ? null : !!r.last_correct,
lastReviewed: r.last_reviewed,
},
}));
if (filter === 'wrong') pool = pool.filter((x) => x.stats.lastCorrect === false);
if (filter === 'unseen') pool = pool.filter((x) => x.stats.reviews === 0);
if (smart) pool = smartOrder(pool, doShuffle);
else if (doShuffle) shuffle(pool);
// sonst: Reihenfolge des Skriptums, so wie sortiert geladen
if (limit > 0) pool = pool.slice(0, limit);
return pool;
}
/**
* Mixes repetition and new material: every third card is the weakest question
* that is due again, the rest is fresh material. Cards that are already sitting
* well (last answer correct, good accuracy, recently seen) go to the back.
*/
function smartOrder(pool, doShuffle = true) {
const unseen = pool.filter((x) => x.stats.reviews === 0);
const fresh = doShuffle ? shuffle(unseen) : unseen;
const seen = pool.filter((x) => x.stats.reviews > 0);
const due = seen.filter(needsRepeat).sort((a, b) => weight(b) - weight(a));
const solid = seen.filter((x) => !needsRepeat(x)).sort((a, b) => weight(b) - weight(a));
const out = [];
while (fresh.length || due.length) {
const takeDue = due.length && (out.length % 3 === 2 || !fresh.length);
out.push(takeDue ? due.shift() : fresh.shift());
}
return out.concat(solid);
}
function needsRepeat(item) {
const s = item.stats;
if (s.lastCorrect === false) return true;
if (s.correct / s.reviews < 0.7) return true;
const ageDays = s.lastReviewed ? (Date.now() - Date.parse(s.lastReviewed)) / 86400000 : 0;
return ageDays >= 7;
}
function weight(item) {
const s = item.stats;
if (s.reviews === 0) return 1000;
const accuracy = s.correct / s.reviews;
let w = (1 - accuracy) * 500;
if (s.lastCorrect === false) w += 300;
const ageDays = s.lastReviewed
? (Date.now() - Date.parse(s.lastReviewed)) / 86400000 : 0;
return w + Math.min(ageDays, 60);
}
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function recordReview(questionId, correct) {
const exists = db.prepare('SELECT id FROM questions WHERE id = ?').get(questionId);
if (!exists) throw new Error('Unbekannte Frage.');
db.prepare('INSERT INTO reviews (question_id, correct, created_at) VALUES (?, ?, ?)')
.run(questionId, correct ? 1 : 0, now());
const row = db.prepare(`SELECT ${QUESTION_STATS} FROM questions q WHERE q.id = ?`).get(questionId);
return {
reviews: row.reviews,
correct: row.correct,
wrong: row.wrong,
lastCorrect: !!row.last_correct,
lastReviewed: row.last_reviewed,
};
}
function undoReview(reviewId) {
const row = db.prepare('SELECT id FROM reviews WHERE id = ?').get(reviewId);
if (!row) return false;
db.prepare('DELETE FROM reviews WHERE id = ?').run(reviewId);
return true;
}
/** Overall stats, per-category accuracy, weakest questions and a 14 day chart. */
function getStats(setId) {
const totals = db.prepare(`
SELECT
(SELECT COUNT(*) FROM questions q
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
WHERE c.set_id = ? AND q.archived = 0) AS questions,
(SELECT COUNT(DISTINCT r.question_id) FROM reviews r
JOIN questions q ON q.id = r.question_id
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
WHERE c.set_id = ? AND q.archived = 0) AS seen,
(SELECT COUNT(*) FROM reviews r
JOIN questions q ON q.id = r.question_id
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
WHERE c.set_id = ?) AS answers,
(SELECT COUNT(*) FROM reviews r
JOIN questions q ON q.id = r.question_id
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
WHERE c.set_id = ? AND r.correct = 1) AS answers_correct`)
.get(setId, setId, setId, setId);
const categories = db.prepare(`
SELECT c.id, c.title,
COUNT(q.id) AS total,
SUM(CASE WHEN (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id) > 0
THEN 1 ELSE 0 END) AS seen,
SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id
ORDER BY r.id DESC LIMIT 1) = 1 THEN 1 ELSE 0 END) AS last_correct,
SUM(CASE WHEN (SELECT r.correct FROM reviews r WHERE r.question_id = q.id
ORDER BY r.id DESC LIMIT 1) = 0 THEN 1 ELSE 0 END) AS last_wrong
FROM categories c
JOIN subcategories sc ON sc.category_id = c.id
JOIN questions q ON q.subcategory_id = sc.id AND q.archived = 0
WHERE c.set_id = ?
GROUP BY c.id
ORDER BY c.position, c.id`).all(setId).map((r) => ({ ...r }));
const weakest = db.prepare(`
SELECT q.id, q.q, c.title AS category, sc.name AS subcategory, ${QUESTION_STATS}
FROM questions q
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
WHERE c.set_id = ? AND q.archived = 0
AND (SELECT COUNT(*) FROM reviews r WHERE r.question_id = q.id AND r.correct = 0) > 0
ORDER BY wrong DESC, correct ASC, last_reviewed DESC
LIMIT 25`).all(setId).map((r) => ({ ...r, last_correct: r.last_correct }));
const daily = db.prepare(`
SELECT substr(r.created_at, 1, 10) AS day,
COUNT(*) AS answers,
SUM(r.correct) AS correct
FROM reviews r
JOIN questions q ON q.id = r.question_id
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
WHERE c.set_id = ?
GROUP BY day
ORDER BY day DESC
LIMIT 14`).all(setId).map((r) => ({ ...r })).reverse();
return { totals: { ...totals }, categories, weakest, daily };
}
function resetStats(setId) {
return tx(() => db.prepare(`
DELETE FROM reviews WHERE question_id IN (
SELECT q.id FROM questions q
JOIN subcategories sc ON sc.id = q.subcategory_id
JOIN categories c ON c.id = sc.category_id
WHERE c.set_id = ?)`).run(setId).changes);
}
function deleteSet(setId) {
return tx(() => db.prepare('DELETE FROM sets WHERE id = ?').run(setId).changes);
}
module.exports = {
db, importSet, normalizeSet, listSets, getSet, getTree, buildSession,
recordReview, undoReview, getStats, resetStats, deleteSet,
};

1983
lap_it_lernkarten.json Normal file

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "lapractice",
"version": "1.0.0",
"description": "Karteikarten-Trainer fuer die LAP-Theoriefragen (Import via JSON, Fortschritt in SQLite)",
"private": true,
"type": "commonjs",
"main": "server.js",
"scripts": {
"start": "node --disable-warning=ExperimentalWarning server.js",
"dev": "node --disable-warning=ExperimentalWarning --watch server.js"
},
"engines": {
"node": ">=22.5.0"
}
}

607
public/app.js Normal file
View File

@ -0,0 +1,607 @@
// LAPractice Karteikarten-Trainer. Alles state-in-memory, Persistenz via API.
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const state = {
sets: [],
setId: Number(localStorage.getItem('lap.setId')) || null,
tree: [],
selection: new Set(),
session: null,
};
/* -------------------------------------------------------------------- api */
async function api(method, url, body) {
const res = await fetch(url, {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
let data = {};
try { data = await res.json(); } catch { /* leerer Body */ }
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
/* ------------------------------------------------------------------ utils */
const esc = (s) => String(s).replace(/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const pct = (part, whole) => (whole ? Math.round((part / whole) * 100) : 0);
function toast(message, kind = '') {
const el = document.createElement('div');
el.className = `toast ${kind}`;
el.textContent = message;
$('#toasts').append(el);
setTimeout(() => el.remove(), kind === 'error' ? 6000 : 3500);
}
function bar(good, bad, total) {
const rest = Math.max(total - good - bad, 0);
return `<div class="bar">
<span class="ok" style="width:${pct(good, total)}%"></span>
<span class="no" style="width:${pct(bad, total)}%"></span>
<span class="neutral" style="width:${pct(rest, total)}%"></span>
</div>`;
}
/* ------------------------------------------------------------ navigation */
function navigate(view) {
$$('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${view}`));
$$('[data-nav]').forEach((b) => b.classList.toggle('active', b.dataset.nav === view));
window.scrollTo({ top: 0, behavior: 'instant' });
if (view === 'stats') loadStats();
}
document.addEventListener('click', (ev) => {
const nav = ev.target.closest('[data-nav]');
if (nav) navigate(nav.dataset.nav);
});
/* --------------------------------------------------------------- library */
async function loadSets() {
const { sets } = await api('GET', '/api/sets');
state.sets = sets;
if (!sets.some((s) => s.id === state.setId)) state.setId = sets[0]?.id ?? null;
renderLibrary();
updateNav();
if (state.setId) await loadTree();
}
function updateNav() {
const has = state.setId !== null;
$('#nav-setup').hidden = !has;
$('#nav-stats').hidden = !has;
}
function renderLibrary() {
const list = $('#set-list');
if (!state.sets.length) {
list.innerHTML = `<div class="empty">Noch kein Fragenset importiert.<br>
Lade oben eine JSON-Datei hoch, um loszulegen.</div>`;
return;
}
list.innerHTML = state.sets.map((s) => {
const known = s.questions - s.seen;
return `<article class="card set-card" data-set="${s.id}">
<div>
<div class="title">${esc(s.title)}</div>
<div class="faint">zuletzt aktualisiert ${new Date(s.updated_at).toLocaleString('de-AT')}</div>
</div>
<div class="meta">
<span class="pill">${s.questions} Fragen</span>
<span class="pill">${s.seen} geübt</span>
<span class="pill">${known} offen</span>
<span class="pill ${s.answers_correct / (s.answers || 1) >= 0.7 ? 'good' : ''}">
${s.answers} Antworten · ${pct(s.answers_correct, s.answers)}% richtig</span>
</div>
<div class="bar">
<span class="seen" style="width:${pct(s.seen, s.questions)}%"></span>
<span class="neutral" style="width:${100 - pct(s.seen, s.questions)}%"></span>
</div>
<div class="actions">
<button class="primary" data-act="practice">Üben</button>
<button class="ghost" data-act="stats">Statistik</button>
<button class="ghost" data-act="reset">Fortschritt zurücksetzen</button>
<div class="spacer"></div>
<button class="danger ghost" data-act="delete">Löschen</button>
</div>
</article>`;
}).join('');
}
$('#set-list').addEventListener('click', async (ev) => {
const btn = ev.target.closest('[data-act]');
if (!btn) return;
const setId = Number(btn.closest('[data-set]').dataset.set);
const set = state.sets.find((s) => s.id === setId);
try {
if (btn.dataset.act === 'practice') {
await selectSet(setId);
navigate('setup');
} else if (btn.dataset.act === 'stats') {
await selectSet(setId);
navigate('stats');
} else if (btn.dataset.act === 'reset') {
if (!confirm(`Alle Antwort-Statistiken von „${set.title}" löschen?`)) return;
const { deletedReviews } = await api('POST', `/api/sets/${setId}/reset`);
toast(`${deletedReviews} Antworten gelöscht.`, 'success');
await loadSets();
} else if (btn.dataset.act === 'delete') {
if (!confirm(`Fragenset „${set.title}" mit allen Statistiken endgültig löschen?`)) return;
await api('DELETE', `/api/sets/${setId}`);
if (state.setId === setId) state.setId = null;
toast('Fragenset gelöscht.', 'success');
await loadSets();
}
} catch (err) {
toast(err.message, 'error');
}
});
async function selectSet(setId) {
state.setId = setId;
localStorage.setItem('lap.setId', String(setId));
updateNav();
await loadTree();
}
/* ---------------------------------------------------------------- import */
const dropzone = $('#dropzone');
$('#pick-file').addEventListener('click', () => $('#file-input').click());
$('#file-input').addEventListener('change', (ev) => importFiles([...ev.target.files]));
['dragenter', 'dragover'].forEach((type) =>
dropzone.addEventListener(type, (ev) => { ev.preventDefault(); dropzone.classList.add('drag'); }));
['dragleave', 'drop'].forEach((type) =>
dropzone.addEventListener(type, (ev) => { ev.preventDefault(); dropzone.classList.remove('drag'); }));
dropzone.addEventListener('drop', (ev) => importFiles([...(ev.dataTransfer?.files || [])]));
async function importFiles(files) {
const usable = files.filter((f) => /\.json$/i.test(f.name) || f.type.includes('json'));
for (const file of files.filter((f) => !usable.includes(f))) {
toast(`${file.name}: keine JSON-Datei übersprungen.`, 'error');
}
for (const file of usable) {
try {
const data = JSON.parse(await file.text());
const { import: r } = await api('POST', '/api/sets', {
data,
filename: file.name.replace(/\.json$/i, ''),
});
toast(r.isNew
? `${r.title}" importiert: ${r.total} Fragen.`
: `${r.title}" aktualisiert: ${r.added} neu, ${r.updated} geändert, ${r.archived} entfernt.`,
'success');
await loadSets();
await selectSet(r.setId);
} catch (err) {
toast(`${file.name}: ${err.message}`, 'error');
}
}
$('#file-input').value = '';
}
/* ----------------------------------------------------------------- setup */
async function loadTree() {
const { set, categories } = await api('GET', `/api/sets/${state.setId}`);
state.tree = categories;
$('#setup-title').textContent = set.title;
$('#stats-title').textContent = `Statistik ${set.title}`;
const stored = JSON.parse(localStorage.getItem(`lap.sel.${set.id}`) || 'null');
const all = categories.flatMap((c) => c.subcategories.map((s) => s.id));
state.selection = new Set(stored ? stored.filter((id) => all.includes(id)) : all);
renderTree();
}
function renderTree() {
$('#tree').innerHTML = state.tree.map((cat) => {
const picked = cat.subcategories.filter((s) => state.selection.has(s.id)).length;
return `<div class="tree-cat ${picked ? 'open' : ''}" data-cat="${cat.id}">
<header>
<span class="twist"></span>
<input type="checkbox" data-cat-check="${cat.id}"
${picked === cat.subcategories.length ? 'checked' : ''}>
<span class="cat-title">${esc(cat.title)}</span>
<span class="faint">${cat.total}</span>
${bar(cat.lastCorrect, cat.lastWrong, cat.total)}
</header>
<div class="tree-subs">
${cat.subcategories.map((sub) => `
<label>
<input type="checkbox" data-sub="${sub.id}" ${state.selection.has(sub.id) ? 'checked' : ''}>
<span>${esc(sub.name)}</span>
<span class="count">${sub.lastCorrect} ${sub.lastWrong} / ${sub.total}</span>
</label>`).join('')}
</div>
</div>`;
}).join('');
updateSelectionInfo();
}
$('#tree').addEventListener('click', (ev) => {
const subBox = ev.target.closest('[data-sub]');
if (subBox) {
const id = Number(subBox.dataset.sub);
subBox.checked ? state.selection.add(id) : state.selection.delete(id);
syncCategoryChecks();
persistSelection();
return;
}
const catBox = ev.target.closest('[data-cat-check]');
if (catBox) {
ev.stopPropagation();
const cat = state.tree.find((c) => c.id === Number(catBox.dataset.catCheck));
for (const sub of cat.subcategories) {
catBox.checked ? state.selection.add(sub.id) : state.selection.delete(sub.id);
}
renderTree();
persistSelection();
return;
}
const header = ev.target.closest('.tree-cat > header');
if (header) header.parentElement.classList.toggle('open');
});
function syncCategoryChecks() {
for (const cat of state.tree) {
const box = $(`[data-cat-check="${cat.id}"]`);
if (!box) continue;
const picked = cat.subcategories.filter((s) => state.selection.has(s.id)).length;
box.checked = picked === cat.subcategories.length;
box.indeterminate = picked > 0 && picked < cat.subcategories.length;
}
updateSelectionInfo();
}
function persistSelection() {
localStorage.setItem(`lap.sel.${state.setId}`, JSON.stringify([...state.selection]));
}
function selectedTotals() {
let total = 0; let seen = 0; let wrong = 0;
for (const cat of state.tree) {
for (const sub of cat.subcategories) {
if (!state.selection.has(sub.id)) continue;
total += sub.total; seen += sub.seen; wrong += sub.lastWrong;
}
}
return { total, unseen: total - seen, wrong };
}
function poolSize() {
const t = selectedTotals();
const filter = $('#filter').value;
return { pool: filter === 'wrong' ? t.wrong : filter === 'unseen' ? t.unseen : t.total, ...t };
}
function updateSelectionInfo() {
const { pool, total } = poolSize();
const subs = state.selection.size;
const opts = [
$('#shuffle').checked ? 'gemischt' : 'Skriptum-Reihenfolge',
$('#smart').checked ? 'Schwächen zuerst' : null,
$('#endless').checked ? 'endlos' : null,
].filter(Boolean).join(' · ');
$('#selection-info').textContent = subs
? `${subs} Themen · ${pool} Fragen im Pool${pool === total ? '' : ` (von ${total})`} · ${opts}`
: 'Nichts ausgewählt';
$('#start').disabled = subs === 0 || pool === 0;
}
$('#setup-all').addEventListener('click', () => {
state.selection = new Set(state.tree.flatMap((c) => c.subcategories.map((s) => s.id)));
renderTree(); persistSelection();
});
$('#setup-none').addEventListener('click', () => {
state.selection.clear();
renderTree(); persistSelection();
});
for (const id of ['filter', 'shuffle', 'smart', 'endless', 'limit']) {
$(`#${id}`).addEventListener('change', () => {
const el = $(`#${id}`);
localStorage.setItem(`lap.${id}`, el.type === 'checkbox' ? String(el.checked) : el.value);
if (id === 'endless') {
$('#limit-row').querySelector('.label').textContent = el.checked ? 'Pro Runde' : 'Anzahl';
}
updateSelectionInfo();
});
}
/** Die im Setup gewählten Optionen als Session-Konfiguration. */
function readOptions() {
return {
subcategoryIds: [...state.selection],
filter: $('#filter').value,
shuffle: $('#shuffle').checked,
smart: $('#smart').checked,
endless: $('#endless').checked,
limit: Number($('#limit').value),
};
}
const apiPayload = ({ endless, ...rest }) => rest;
/* -------------------------------------------------------------- practice */
$('#start').addEventListener('click', () => startSession());
async function startSession(override) {
const config = override || readOptions();
try {
const { questions } = await api('POST', `/api/sets/${state.setId}/session`, apiPayload(config));
if (!questions.length) {
toast('Keine Fragen gefunden andere Auswahl oder Filter probieren.', 'error');
return;
}
state.session = { questions, index: 0, results: [], config, endless: !!config.endless, cycle: 1 };
navigate('practice');
renderCard();
} catch (err) {
toast(err.message, 'error');
}
}
/**
* Endlos-Modus: ist der Stapel durch, wird die naechste Runde geholt. Der
* Server sortiert dabei mit den frisch gespeicherten Ergebnissen, sodass gerade
* falsch beantwortete Fragen wieder nach vorne rutschen.
*/
async function nextRound() {
const s = state.session;
$('#p-question').textContent = 'Nächste Runde wird geladen …';
let next = [];
try {
({ questions: next } = await api('POST', `/api/sets/${state.setId}/session`, apiPayload(s.config)));
} catch (err) {
toast(err.message, 'error');
}
if (!next.length) next = s.questions.slice(); // Fallback: dieselben Karten nochmal
if (!next.length) return finishSession();
s.cycle++;
s.questions = next;
s.index = 0;
toast(`Runde ${s.cycle} · ${next.length} Karten`);
renderCard();
}
function currentCard() {
const s = state.session;
return s ? s.questions[s.index] : null;
}
function renderCard() {
const s = state.session;
const card = currentCard();
if (!card) return s.endless ? nextRound() : finishSession();
$('#p-position').textContent = s.endless
? `Karte ${s.results.length + 1} · Runde ${s.cycle}`
: `${s.index + 1} / ${s.questions.length}`;
$('#p-progress').style.width = `${pct(s.index, s.questions.length)}%`;
$('#p-correct').textContent = `${s.results.filter((r) => r.correct).length}`;
$('#p-wrong').textContent = `${s.results.filter((r) => r.correct === false).length}`;
$('#p-category').textContent = card.category;
$('#p-subcategory').textContent = card.subcategory;
const hist = $('#p-history');
if (card.stats.reviews > 0) {
hist.hidden = false;
hist.className = `pill ${card.stats.lastCorrect ? 'good' : 'bad'}`;
hist.textContent = `bisher ${card.stats.correct}${card.stats.wrong}`;
} else {
hist.hidden = false;
hist.className = 'pill accent';
hist.textContent = 'neu';
}
$('#p-question').textContent = card.q;
$('#p-answer-text').textContent = card.a;
setRevealed(false);
}
function setRevealed(revealed) {
state.session.revealed = revealed;
$('#p-answer').hidden = !revealed;
$('#p-judge').hidden = !revealed;
$('#p-reveal').hidden = revealed;
if (!revealed) $('#p-reveal').focus({ preventScroll: true });
}
$('#p-reveal').addEventListener('click', () => setRevealed(true));
$('#p-yes').addEventListener('click', () => judge(true));
$('#p-no').addEventListener('click', () => judge(false));
$('#p-quit').addEventListener('click', () => {
if (state.session?.results.length) finishSession();
else navigate('setup');
});
async function judge(correct) {
const s = state.session;
const card = currentCard();
if (!s || !card || !s.revealed) return;
s.results.push({ ...card, correct });
if (s.endless && !correct) {
// Falsche Karte kommt noch in dieser Runde wieder.
s.questions.splice(Math.min(s.index + 5, s.questions.length), 0, card);
}
s.index++;
renderCard();
try {
const { stats } = await api('POST', '/api/reviews', { questionId: card.id, correct });
card.stats = stats;
} catch (err) {
toast(`Antwort nicht gespeichert: ${err.message}`, 'error');
}
}
function skipCard() {
const s = state.session;
if (!s) return;
s.results.push({ ...currentCard(), correct: null });
s.index++;
renderCard();
}
document.addEventListener('keydown', (ev) => {
if (!$('#view-practice').classList.contains('active')) return;
if (ev.target.matches('input, select, textarea')) return;
const s = state.session;
if (!s) return;
if (ev.key === 'Escape') { ev.preventDefault(); $('#p-quit').click(); return; }
if (ev.code === 'Space' || ev.key === 'Enter') {
ev.preventDefault();
if (!s.revealed) setRevealed(true);
return;
}
if (ev.key.toLowerCase() === 's') { ev.preventDefault(); skipCard(); return; }
if (!s.revealed) return;
if (ev.key === '2' || ev.key === 'ArrowRight') { ev.preventDefault(); judge(true); }
else if (ev.key === '1' || ev.key === 'ArrowLeft') { ev.preventDefault(); judge(false); }
});
/* -------------------------------------------------------------- summary */
function finishSession() {
const s = state.session;
const answered = s.results.filter((r) => r.correct !== null);
const correct = answered.filter((r) => r.correct).length;
const wrong = answered.length - correct;
const skipped = s.results.length - answered.length;
$('#summary-scores').innerHTML = `
<div class="score"><div class="num">${answered.length}</div><div class="lbl">beantwortet</div></div>
<div class="score ok"><div class="num">${correct}</div><div class="lbl">richtig</div></div>
<div class="score no"><div class="num">${wrong}</div><div class="lbl">falsch</div></div>
<div class="score"><div class="num">${pct(correct, answered.length)}%</div><div class="lbl">Trefferquote</div></div>
${skipped ? `<div class="score"><div class="num">${skipped}</div><div class="lbl">übersprungen</div></div>` : ''}`;
const order = [...s.results].sort((a, b) => (a.correct === false ? -1 : 1) - (b.correct === false ? -1 : 1));
$('#summary-list').innerHTML = order.map((r) => `
<div class="review-item ${r.correct ? 'ok' : ''}">
<div class="q">${r.correct === null ? '⤼' : r.correct ? '✓' : '✗'} ${esc(r.q)}</div>
<div class="a">${esc(r.a)}</div>
<div class="faint" style="margin-top:6px">${esc(r.category)} ${esc(r.subcategory)}</div>
</div>`).join('');
$('#again-wrong').disabled = wrong === 0;
$('#again-wrong').dataset.ids = JSON.stringify(
s.results.filter((r) => r.correct === false).map((r) => r.id));
navigate('summary');
loadSets().catch(() => {});
}
$('#again-wrong').addEventListener('click', () => {
const ids = new Set(JSON.parse($('#again-wrong').dataset.ids || '[]'));
const questions = state.session.results
.filter((r) => ids.has(r.id))
.map(({ correct, ...q }) => q);
state.session = {
questions, index: 0, results: [],
config: { ...state.session.config, endless: false }, endless: false, cycle: 1,
};
navigate('practice');
renderCard();
});
$('#again-same').addEventListener('click', () => startSession(state.session.config));
/* ----------------------------------------------------------------- stats */
async function loadStats() {
if (!state.setId) return;
try {
const data = await api('GET', `/api/sets/${state.setId}/stats`);
renderStats(data);
} catch (err) {
toast(err.message, 'error');
}
}
function renderStats({ set, totals, categories, weakest, daily }) {
$('#stats-title').textContent = `Statistik ${set.title}`;
$('#stats-scores').innerHTML = `
<div class="score"><div class="num">${totals.questions}</div><div class="lbl">Fragen</div></div>
<div class="score"><div class="num">${totals.seen}</div><div class="lbl">davon geübt</div></div>
<div class="score"><div class="num">${totals.answers}</div><div class="lbl">Antworten</div></div>
<div class="score ${pct(totals.answers_correct, totals.answers) >= 70 ? 'ok' : 'no'}">
<div class="num">${pct(totals.answers_correct, totals.answers)}%</div>
<div class="lbl">richtig gesamt</div></div>`;
const byDay = new Map(daily.map((d) => [d.day, d]));
const days = [...Array(14)].map((_, i) => {
const date = new Date();
date.setDate(date.getDate() - (13 - i));
const key = date.toISOString().slice(0, 10);
return byDay.get(key) || { day: key, answers: 0, correct: 0 };
});
const max = Math.max(1, ...days.map((d) => d.answers));
$('#stats-days').innerHTML = days.map((d) => {
const h = d.answers ? Math.round((d.answers / max) * 68) + 4 : 3;
const okShare = pct(d.correct, d.answers);
return `<div class="day" title="${d.day}: ${d.correct}/${d.answers} richtig">
<div class="stack ${d.answers ? '' : 'idle'}" style="height:${h}px">
${d.answers ? `<span class="ok" style="height:${okShare}%"></span>
<span class="no" style="height:${100 - okShare}%"></span>` : ''}
</div>
<div class="lbl">${d.day.slice(8)}.${d.day.slice(5, 7)}</div>
</div>`;
}).join('');
$('#stats-categories').innerHTML = categories.map((c) => `
<div class="stat-row">
<span class="name" title="${esc(c.title)}">${esc(c.title)}</span>
${bar(c.last_correct, c.last_wrong, c.total)}
<span class="pct">${pct(c.last_correct, c.total)}% · ${c.total}</span>
</div>`).join('');
$('#stats-weakest').innerHTML = weakest.length
? weakest.map((w) => `
<div class="stat-row">
<span class="name" title="${esc(w.q)}">${esc(w.q)}
<span class="faint"> · ${esc(w.category)}</span></span>
${bar(w.correct, w.wrong, w.reviews)}
<span class="pct">${w.correct} ${w.wrong}</span>
</div>`).join('')
: '<div class="faint">Keine falsch beantworteten Fragen stark.</div>';
}
$('#stats-reset').addEventListener('click', async () => {
const set = state.sets.find((s) => s.id === state.setId);
if (!confirm(`Alle Antwort-Statistiken von „${set?.title}" löschen?`)) return;
try {
const { deletedReviews } = await api('POST', `/api/sets/${state.setId}/reset`);
toast(`${deletedReviews} Antworten gelöscht.`, 'success');
await loadSets();
await loadStats();
} catch (err) {
toast(err.message, 'error');
}
});
/* ------------------------------------------------------------------ boot */
$('#filter').value = localStorage.getItem('lap.filter') || 'all';
$('#limit').value = localStorage.getItem('lap.limit') || '20';
for (const id of ['shuffle', 'smart', 'endless']) {
const stored = localStorage.getItem(`lap.${id}`);
if (stored !== null) $(`#${id}`).checked = stored === 'true';
}
$('#limit-row').querySelector('.label').textContent = $('#endless').checked ? 'Pro Runde' : 'Anzahl';
loadSets()
.then(() => navigate(state.setId ? 'setup' : 'library'))
.catch((err) => { toast(err.message, 'error'); navigate('library'); });

207
public/index.html Normal file
View File

@ -0,0 +1,207 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<title>LAPractice LAP Lernkarten</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎓</text></svg>">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand"><span class="logo">LP</span> LAPractice</div>
<nav>
<button class="tab ghost" data-nav="library">Fragensets</button>
<button class="tab ghost" data-nav="setup" id="nav-setup" hidden>Üben</button>
<button class="tab ghost" data-nav="stats" id="nav-stats" hidden>Statistik</button>
</nav>
</header>
<!-- ------------------------------------------------------------ library -->
<section class="view" id="view-library">
<div class="section-head">
<h2>Fragenset importieren</h2>
<span class="hint">JSON-Datei mit Kategorien, Unterkategorien und Fragen</span>
</div>
<div class="dropzone" id="dropzone">
<h3>Datei hierher ziehen</h3>
<p class="muted">oder</p>
<button class="primary" id="pick-file">Datei auswählen</button>
<input type="file" id="file-input" accept=".json,application/json" hidden multiple>
<p class="faint" style="margin-top:14px">
Erwartetes Format:
<code>{ "title": …, "categories": [ { "title": …, "subcategories": [ { "name": …, "questions": [ { "q": …, "a": … } ] } ] } ] }</code><br>
Ein Set mit gleichem Titel erneut importieren aktualisiert die Fragen und behält die Statistik.
</p>
</div>
<div class="section-head">
<h2>Meine Fragensets</h2>
<div class="spacer"></div>
</div>
<div class="set-list" id="set-list"></div>
</section>
<!-- -------------------------------------------------------------- setup -->
<section class="view" id="view-setup">
<div class="section-head">
<h2 id="setup-title">Übung zusammenstellen</h2>
<div class="spacer"></div>
<button class="ghost" id="setup-all">Alle wählen</button>
<button class="ghost" id="setup-none">Keine</button>
</div>
<div class="setup-grid">
<div class="card options">
<div class="option-row">
<span class="label">Fragen</span>
<select id="filter">
<option value="all">Alle ausgewählten Fragen</option>
<option value="wrong">Nur zuletzt falsch beantwortete</option>
<option value="unseen">Nur noch nie gesehene</option>
</select>
</div>
<div class="option-row">
<span class="label">Reihenfolge</span>
<label class="toggle">
<input type="checkbox" id="shuffle" checked>
<span>Fragen mischen</span>
<span class="faint"> zufällige Reihenfolge statt Skriptum</span>
</label>
</div>
<div class="option-row">
<span class="label"></span>
<label class="toggle">
<input type="checkbox" id="smart" checked>
<span>Schwächen zuerst</span>
<span class="faint"> neue und falsch beantwortete Fragen nach vorne</span>
</label>
</div>
<div class="option-row">
<span class="label">Umfang</span>
<label class="toggle">
<input type="checkbox" id="endless">
<span>Endlos üben</span>
<span class="faint"> wiederholt, bis ich die Runde beende</span>
</label>
</div>
<div class="option-row" id="limit-row">
<span class="label">Anzahl</span>
<select id="limit">
<option value="10">10 Karten</option>
<option value="20" selected>20 Karten</option>
<option value="30">30 Karten</option>
<option value="50">50 Karten</option>
<option value="0">Alle</option>
</select>
</div>
</div>
<div class="tree" id="tree"></div>
</div>
<div class="start-bar">
<span class="muted" id="selection-info">Nichts ausgewählt</span>
<div class="spacer"></div>
<button class="ghost" data-nav="library">Zurück</button>
<button class="primary big" id="start">Übung starten</button>
</div>
</section>
<!-- ----------------------------------------------------------- practice -->
<section class="view" id="view-practice">
<div class="practice-head">
<div class="row">
<span class="counter muted" id="p-position"></span>
<span class="pill good" id="p-correct">✓ 0</span>
<span class="pill bad" id="p-wrong">✗ 0</span>
<div class="spacer"></div>
<button class="ghost" id="p-quit">Beenden</button>
</div>
<div class="progress"><span id="p-progress" style="width:0%"></span></div>
</div>
<div class="flashcard">
<div class="crumbs">
<span class="pill accent" id="p-category"></span>
<span class="pill" id="p-subcategory"></span>
<span class="spacer"></span>
<span class="pill" id="p-history" hidden></span>
</div>
<div class="question" id="p-question"></div>
<div class="answer" id="p-answer" hidden>
<span class="lbl">Antwort</span>
<div id="p-answer-text"></div>
</div>
<div class="card-actions">
<button class="primary big" id="p-reveal" style="flex:1">Antwort aufdecken</button>
<div class="judge" id="p-judge" hidden>
<button class="wrong big" id="p-no">✗ Falsch</button>
<button class="correct big" id="p-yes">✓ Richtig</button>
</div>
</div>
</div>
<div class="keyhints">
<span><kbd>Leertaste</kbd> aufdecken</span>
<span><kbd>1</kbd> / <kbd></kbd> falsch</span>
<span><kbd>2</kbd> / <kbd></kbd> richtig</span>
<span><kbd>S</kbd> überspringen</span>
<span><kbd>Esc</kbd> Runde beenden</span>
</div>
</section>
<!-- ------------------------------------------------------------ summary -->
<section class="view" id="view-summary">
<div class="section-head"><h2>Runde beendet</h2></div>
<div class="score-grid" id="summary-scores"></div>
<div class="section-head">
<h2>Durchgang</h2>
<div class="spacer"></div>
<button class="primary" id="again-wrong">Falsche wiederholen</button>
<button class="ghost" id="again-same">Neue Runde</button>
<button class="ghost" data-nav="setup">Auswahl ändern</button>
</div>
<div class="review-list" id="summary-list"></div>
</section>
<!-- -------------------------------------------------------------- stats -->
<section class="view" id="view-stats">
<div class="section-head">
<h2 id="stats-title">Statistik</h2>
<div class="spacer"></div>
<button class="danger ghost" id="stats-reset">Fortschritt zurücksetzen</button>
</div>
<div class="score-grid" id="stats-scores"></div>
<div class="section-head"><h2>Letzte 14 Tage</h2></div>
<div class="card"><div class="days" id="stats-days"></div></div>
<div class="section-head">
<h2>Kategorien</h2>
<span class="hint">Anteil der Fragen, die zuletzt richtig beantwortet wurden</span>
</div>
<div class="stat-rows" id="stats-categories"></div>
<div class="section-head">
<h2>Schwächste Fragen</h2>
<span class="hint">nach Anzahl falscher Antworten</span>
</div>
<div class="stat-rows" id="stats-weakest"></div>
</section>
</div>
<div class="toasts" id="toasts"></div>
<script type="module" src="/app.js"></script>
</body>
</html>

321
public/style.css Normal file
View File

@ -0,0 +1,321 @@
/* ------------------------------------------------------------------ theme */
:root {
--bg: #0f1116;
--bg-elev: #171a21;
--bg-elev-2: #1e222b;
--border: #2a2f3a;
--border-strong: #3a4150;
--text: #e8eaef;
--text-dim: #a0a7b6;
--text-faint: #6d7484;
--accent: #6a8dff;
--accent-soft: rgba(106, 141, 255, 0.14);
--good: #3ecf8e;
--good-soft: rgba(62, 207, 142, 0.14);
--bad: #ff6b6b;
--bad-soft: rgba(255, 107, 107, 0.14);
--warn: #f0b840;
--radius: 14px;
--radius-sm: 9px;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f5f8;
--bg-elev: #ffffff;
--bg-elev-2: #eef0f5;
--border: #dfe3ec;
--border-strong: #c6ccdb;
--text: #171a21;
--text-dim: #59606f;
--text-faint: #878e9d;
--accent: #3f63d8;
--accent-soft: rgba(63, 99, 216, 0.1);
--good: #14915d;
--good-soft: rgba(20, 145, 93, 0.12);
--bad: #d13d3d;
--bad-soft: rgba(209, 61, 61, 0.12);
--warn: #b07908;
--shadow: 0 10px 30px rgba(20, 25, 40, 0.1);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 16px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { margin: 0; line-height: 1.25; font-weight: 650; letter-spacing: -0.01em; }
p { margin: 0 0 0.8em; }
/* ----------------------------------------------------------------- layout */
.app { max-width: 980px; margin: 0 auto; padding: 0 18px 80px; }
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 14px;
padding: 14px 0 12px;
background: linear-gradient(var(--bg) 72%, transparent);
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: -0.02em; }
.brand .logo {
width: 30px; height: 30px; border-radius: 9px;
background: linear-gradient(135deg, var(--accent), #a06aff);
display: grid; place-items: center; color: #fff; font-size: 15px; font-weight: 800;
}
.topbar nav { display: flex; gap: 4px; margin-left: auto; flex-wrap: wrap; }
.view { display: none; animation: fade 0.18s ease; }
.view.active { display: block; }
@keyframes fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.section-head {
display: flex; align-items: baseline; gap: 12px;
flex-wrap: wrap; margin: 26px 0 14px;
}
.section-head h2 { font-size: 20px; }
.section-head .hint { color: var(--text-dim); font-size: 13px; }
.spacer { flex: 1; }
/* ------------------------------------------------------------------ atoms */
.card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
}
button, .btn {
font: inherit;
font-weight: 550;
color: var(--text);
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 9px 15px;
cursor: pointer;
transition: background 0.12s, border-color 0.12s, transform 0.06s;
}
button:hover { border-color: var(--border-strong); }
button:active { transform: translateY(1px); }
button:disabled { opacity: 0.45; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: transparent; color: #fff; }
button.primary:hover { filter: brightness(1.08); }
button.ghost { background: transparent; }
button.ghost:hover { background: var(--bg-elev-2); }
button.danger { color: var(--bad); }
button.danger:hover { background: var(--bad-soft); border-color: var(--bad); }
button.tab.active { background: var(--accent-soft); border-color: transparent; color: var(--accent); }
button.big { padding: 14px 22px; font-size: 16px; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 10px; border-radius: 999px;
background: var(--bg-elev-2); border: 1px solid var(--border);
font-size: 12px; color: var(--text-dim); white-space: nowrap;
}
.pill.good { color: var(--good); background: var(--good-soft); border-color: transparent; }
.pill.bad { color: var(--bad); background: var(--bad-soft); border-color: transparent; }
.pill.accent { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.bar { height: 7px; border-radius: 999px; background: var(--bg-elev-2); overflow: hidden; display: flex; }
.bar > span { display: block; height: 100%; }
.bar .ok { background: var(--good); }
.bar .no { background: var(--bad); }
.bar .neutral { background: var(--border-strong); }
.bar .seen { background: var(--accent); }
kbd {
font: 11px/1 var(--mono);
padding: 3px 6px; border-radius: 5px;
background: var(--bg); border: 1px solid var(--border-strong); color: var(--text-dim);
}
.muted { color: var(--text-dim); }
.faint { color: var(--text-faint); font-size: 13px; }
.empty {
border: 1px dashed var(--border-strong); border-radius: var(--radius);
padding: 34px; text-align: center; color: var(--text-dim);
}
/* --------------------------------------------------------------- library */
.dropzone {
border: 2px dashed var(--border-strong);
border-radius: var(--radius);
padding: 30px 20px;
text-align: center;
background: var(--bg-elev);
transition: border-color 0.15s, background 0.15s;
}
.dropzone.drag { border-color: var(--accent); background: var(--accent-soft); }
.dropzone h3 { font-size: 16px; margin-bottom: 6px; }
.set-list { display: grid; gap: 12px; }
.set-card { display: grid; gap: 12px; }
.set-card .title { font-size: 17px; font-weight: 650; }
.set-card .meta { display: flex; gap: 8px; flex-wrap: wrap; }
.set-card .actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ----------------------------------------------------------------- setup */
.setup-grid { display: grid; gap: 16px; grid-template-columns: 1fr; }
.options { display: grid; gap: 10px; }
.option-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.option-row > .label { min-width: 96px; color: var(--text-dim); font-size: 13px; }
.toggle {
display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 5px 9px; border-radius: var(--radius-sm); border: 1px solid transparent;
}
.toggle:hover { background: var(--bg-elev-2); }
.toggle:has(input:checked) { border-color: var(--border-strong); background: var(--accent-soft); }
.toggle .faint { font-size: 12px; }
@media (max-width: 620px) { .toggle .faint { display: none; } }
.tree { display: grid; gap: 8px; }
.tree-cat {
border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--bg-elev); overflow: hidden;
}
.tree-cat > header {
display: flex; align-items: center; gap: 10px; padding: 11px 13px; cursor: pointer;
}
.tree-cat > header:hover { background: var(--bg-elev-2); }
.tree-cat .cat-title { font-weight: 600; flex: 1; min-width: 0; }
.tree-cat .bar { width: 110px; flex: none; }
.tree-cat .twist { color: var(--text-faint); transition: transform 0.15s; font-size: 12px; }
.tree-cat.open .twist { transform: rotate(90deg); }
.tree-subs { display: none; padding: 2px 13px 12px 40px; }
.tree-cat.open .tree-subs { display: grid; gap: 2px; }
.tree-subs label {
display: flex; align-items: center; gap: 9px; padding: 5px 6px;
border-radius: 7px; cursor: pointer; font-size: 14px;
}
.tree-subs label:hover { background: var(--bg-elev-2); }
.tree-subs .count { margin-left: auto; color: var(--text-faint); font-size: 12px; font-family: var(--mono); }
input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
select, input[type="number"] {
font: inherit; color: var(--text); background: var(--bg-elev-2);
border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 11px;
}
.start-bar {
position: sticky; bottom: 0; margin-top: 16px;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 13px; border-radius: var(--radius);
background: var(--bg-elev); border: 1px solid var(--border); box-shadow: var(--shadow);
}
/* -------------------------------------------------------------- practice */
.practice-head { display: grid; gap: 10px; margin: 8px 0 18px; }
.practice-head .row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.progress { height: 6px; border-radius: 999px; background: var(--bg-elev-2); overflow: hidden; }
.progress > span { display: block; height: 100%; background: var(--accent); transition: width 0.25s ease; }
.counter { font-family: var(--mono); font-size: 13px; }
.flashcard {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 18px;
box-shadow: var(--shadow);
padding: 26px;
min-height: 260px;
display: flex; flex-direction: column; gap: 18px;
}
.flashcard .crumbs { display: flex; gap: 8px; flex-wrap: wrap; }
.flashcard .question {
font-size: clamp(20px, 3vw, 26px); font-weight: 600; letter-spacing: -0.015em;
white-space: pre-wrap;
}
.flashcard .answer {
border-top: 1px solid var(--border); padding-top: 18px;
font-size: 17px; color: var(--text);
animation: reveal 0.22s ease;
}
.flashcard .answer > div { white-space: pre-wrap; }
.flashcard .answer .lbl {
display: block; font-size: 11px; letter-spacing: 0.09em; text-transform: uppercase;
color: var(--text-faint); margin-bottom: 7px;
}
@keyframes reveal { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
.card-actions { margin-top: auto; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.judge { display: flex; gap: 10px; flex: 1; }
.judge button { flex: 1; display: flex; justify-content: center; align-items: center; gap: 9px; }
button.correct { background: var(--good-soft); border-color: var(--good); color: var(--good); }
button.correct:hover { background: var(--good); color: #06130d; }
button.wrong { background: var(--bad-soft); border-color: var(--bad); color: var(--bad); }
button.wrong:hover { background: var(--bad); color: #210909; }
.keyhints { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 14px; color: var(--text-faint); font-size: 12px; }
.keyhints span { display: inline-flex; align-items: center; gap: 5px; }
/* --------------------------------------------------------------- summary */
.score-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
.score {
background: var(--bg-elev); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px;
}
.score .num { font-size: 30px; font-weight: 700; letter-spacing: -0.03em; }
.score .lbl { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.07em; }
.score.ok .num { color: var(--good); }
.score.no .num { color: var(--bad); }
.review-list { display: grid; gap: 8px; }
.review-item {
border: 1px solid var(--border); border-left: 3px solid var(--bad);
border-radius: var(--radius-sm); background: var(--bg-elev); padding: 12px 14px;
}
.review-item.ok { border-left-color: var(--good); }
.review-item .q { font-weight: 550; }
.review-item .a { color: var(--text-dim); font-size: 14px; margin-top: 6px; white-space: pre-wrap; }
/* ----------------------------------------------------------------- stats */
.stat-rows { display: grid; gap: 8px; }
.stat-row {
display: grid; grid-template-columns: 1fr 130px 84px; gap: 12px; align-items: center;
padding: 10px 13px; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--bg-elev);
}
.stat-row .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stat-row .pct { text-align: right; font-family: var(--mono); font-size: 13px; color: var(--text-dim); }
.days { display: flex; gap: 6px; align-items: flex-end; height: 90px; }
.days .day {
flex: 1; max-width: 46px; display: flex; flex-direction: column;
justify-content: flex-end; gap: 4px;
}
.days .day .stack.idle { background: var(--bg-elev-2); border-radius: 3px; }
.days .day .stack { border-radius: 4px 4px 0 0; overflow: hidden; display: flex; flex-direction: column-reverse; }
.days .day .ok { background: var(--good); }
.days .day .no { background: var(--bad); }
.days .lbl { font-size: 10px; color: var(--text-faint); text-align: center; }
/* ---------------------------------------------------------------- toasts */
.toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 50; }
.toast {
background: var(--bg-elev); border: 1px solid var(--border-strong);
border-left: 3px solid var(--accent); border-radius: var(--radius-sm);
padding: 11px 15px; box-shadow: var(--shadow); max-width: 380px;
animation: fade 0.2s ease;
}
.toast.error { border-left-color: var(--bad); }
.toast.success { border-left-color: var(--good); }
@media (max-width: 620px) {
.app { padding: 0 12px 60px; }
.topbar { flex-wrap: wrap; }
.flashcard { padding: 20px; border-radius: 14px; }
.stat-row { grid-template-columns: 1fr 70px; }
.stat-row .bar { display: none; }
.judge { flex-direction: column; }
}

175
server.js Normal file
View File

@ -0,0 +1,175 @@
'use strict';
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const store = require('./db');
const PORT = Number(process.env.PORT) || 4173;
const HOST = process.env.HOST || '127.0.0.1';
const PUBLIC_DIR = path.join(__dirname, 'public');
const MAX_UPLOAD = 32 * 1024 * 1024; // 32 MB is plenty for a question set
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
};
function sendJson(res, status, body) {
const payload = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(payload),
'Cache-Control': 'no-store',
});
res.end(payload);
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_UPLOAD) {
reject(Object.assign(new Error('Datei ist zu gross.'), { status: 413 }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw.trim()) return resolve({});
try {
resolve(JSON.parse(raw));
} catch {
reject(Object.assign(new Error('Ungueltiges JSON.'), { status: 400 }));
}
});
req.on('error', reject);
});
}
function serveStatic(req, res, pathname) {
const rel = pathname === '/' ? 'index.html' : pathname.slice(1);
const filePath = path.join(PUBLIC_DIR, rel);
if (!filePath.startsWith(PUBLIC_DIR + path.sep)) {
return sendJson(res, 403, { error: 'Verboten.' });
}
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
return res.end('404 nicht gefunden');
}
res.writeHead(200, {
'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream',
'Content-Length': data.length,
'Cache-Control': 'no-cache',
});
res.end(data);
});
}
const routes = [
['GET', /^\/api\/sets$/, () => ({ sets: store.listSets() })],
['POST', /^\/api\/sets$/, async (req, res, m, body) => {
const raw = body && body.data !== undefined ? body.data : body;
const result = store.importSet(raw, body && body.filename);
return { import: result, sets: store.listSets() };
}],
['GET', /^\/api\/sets\/(\d+)$/, (req, res, m) => {
const set = store.getSet(Number(m[1]));
if (!set) throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 });
return { set, categories: store.getTree(set.id) };
}],
['DELETE', /^\/api\/sets\/(\d+)$/, (req, res, m) => {
const changes = store.deleteSet(Number(m[1]));
if (!changes) throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 });
return { deleted: true, sets: store.listSets() };
}],
['POST', /^\/api\/sets\/(\d+)\/reset$/, (req, res, m) => ({
deletedReviews: store.resetStats(Number(m[1])),
})],
['GET', /^\/api\/sets\/(\d+)\/stats$/, (req, res, m) => {
const set = store.getSet(Number(m[1]));
if (!set) throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 });
return { set, ...store.getStats(set.id) };
}],
['POST', /^\/api\/sets\/(\d+)\/session$/, (req, res, m, body) => {
const setId = Number(m[1]);
if (!store.getSet(setId)) {
throw Object.assign(new Error('Fragenset nicht gefunden.'), { status: 404 });
}
const ids = Array.isArray(body.subcategoryIds)
? body.subcategoryIds.map(Number).filter(Number.isInteger) : [];
const questions = store.buildSession({
setId,
subcategoryIds: ids,
filter: String(body.filter || 'all'),
shuffle: body.shuffle !== false,
smart: body.smart !== false,
limit: Number(body.limit) || 0,
});
return { questions };
}],
['POST', /^\/api\/reviews$/, (req, res, m, body) => {
const questionId = Number(body.questionId);
if (!Number.isInteger(questionId)) {
throw Object.assign(new Error('questionId fehlt.'), { status: 400 });
}
if (typeof body.correct !== 'boolean') {
throw Object.assign(new Error('correct muss true oder false sein.'), { status: 400 });
}
return { stats: store.recordReview(questionId, body.correct) };
}],
];
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const pathname = decodeURIComponent(url.pathname);
if (!pathname.startsWith('/api/')) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return sendJson(res, 405, { error: 'Methode nicht erlaubt.' });
}
return serveStatic(req, res, pathname);
}
let pathMatched = false;
for (const [method, pattern, handler] of routes) {
const m = pattern.exec(pathname);
if (!m) continue;
pathMatched = true;
if (method !== req.method) continue;
try {
const body = req.method === 'GET' || req.method === 'DELETE' ? {} : await readBody(req);
const result = await handler(req, res, m, body);
if (!res.writableEnded) sendJson(res, 200, result ?? { ok: true });
} catch (err) {
const status = err.status || 400;
if (!err.status) console.error(err);
if (!res.writableEnded) sendJson(res, status, { error: err.message || 'Fehler.' });
}
return;
}
if (pathMatched) return sendJson(res, 405, { error: 'Methode nicht erlaubt.' });
sendJson(res, 404, { error: 'Unbekannter Endpunkt.' });
});
server.listen(PORT, HOST, () => {
console.log(`LAPractice laeuft auf http://${HOST}:${PORT}`);
});