From 7b886c43558c08f04a63963d8a832c2f9d1f598b Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Sun, 26 Apr 2026 11:01:58 +0200 Subject: [PATCH] Implement Wettkampf mode with random type-aware question selection and answer persistence --- public/js/app.js | 662 +++++++++++++++++++++++++++++++++++++++++++++- server/baserow.js | 10 +- server/index.js | 132 ++++++++- 3 files changed, 797 insertions(+), 7 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 85b6e71..1d6e4c5 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -11,6 +11,11 @@ const api = { if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText); return r.json(); }, + async put(url, data) { + const r = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.statusText); + return r.json(); + }, }; function wsUrl() { return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`; } @@ -157,8 +162,9 @@ function HomePage({ navigate }) {

Quizalarm

Lernen. Quizzen. Wissen.

- + +
Admin-Bereich → @@ -666,6 +672,655 @@ function SoloResultPage({ params, navigate }) { ); } +/* ================================================================== */ +/* Wettkampf Select Page */ +/* ================================================================== */ + +function WettkampfSelectPage({ navigate }) { + const [sets, setSets] = useState([]); + const [name, setName] = useState(''); + const [selectedSet, setSelectedSet] = useState(null); + const [questionCount, setQuestionCount] = useState(''); + const [totalQuestions, setTotalQuestions] = useState(null); + const [error, setError] = useState(''); + const [checking, setChecking] = useState(false); + const [progressDialog, setProgressDialog] = useState(null); + + useEffect(() => { api.get('/api/sets').then(setSets).catch((e) => setError(e.message)); }, []); + + const selectSet = async (set) => { + setSelectedSet(set); + setQuestionCount(''); + setTotalQuestions(null); + try { + const data = await api.get(`/api/sets/${set.id}/count`); + setTotalQuestions(data.count); + } catch (e) { + setError(e.message); + } + }; + + const startWettkampf = async () => { + if (!name.trim()) return setError('Bitte Name eingeben'); + const count = parseInt(questionCount); + if (!count || count < 1) return setError('Bitte Anzahl der Fragen eingeben'); + if (count > totalQuestions) return setError(`Maximal ${totalQuestions} Fragen verfügbar`); + setChecking(true); + setError(''); + try { + const progress = await api.get(`/api/wettkampf-progress/${encodeURIComponent(name.trim())}/${selectedSet.id}`); + if (progress.hasProgress) { + setProgressDialog({ ...progress, count }); + } else { + navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: count }); + } + } catch (e) { + navigate('wettkampf-quiz', { setId: selectedSet.id, setName: selectedSet.name, name: name.trim(), questionCount: count }); + } + setChecking(false); + }; + + const continueSession = () => { + navigate('wettkampf-quiz', { + setId: selectedSet.id, setName: selectedSet.name, + name: name.trim(), questionCount: progressDialog.count, + continueSessionId: progressDialog.sessionId, + priorAnswers: progressDialog.answers, + }); + setProgressDialog(null); + }; + + const startFresh = () => { + navigate('wettkampf-quiz', { + setId: selectedSet.id, setName: selectedSet.name, + name: name.trim(), questionCount: parseInt(questionCount), + }); + setProgressDialog(null); + }; + + return ( +
+
navigate('home')} label="Zurück" />
+

⚔️ Wettkampf

+
+ setName(e.target.value)} /> + + {progressDialog && ( +
+

📌 Gespeicherter Fortschritt

+

{selectedSet.name}: {progressDialog.totalAnswered} Fragen beantwortet

+
+ + +
+
+ )} + + {!progressDialog && ( + <> + {!selectedSet ? ( + <> +

Fragenset wählen:

+ {sets.length === 0 &&

Keine Fragensets konfiguriert.

} + {sets.map((s) => ( + + ))} + + ) : ( + <> +
+
+ Fragenset: +

{selectedSet.name}

+
+ +
+ + {totalQuestions !== null && ( +
+ + setQuestionCount(e.target.value)} /> +
+ )} + + {error &&

{error}

} + + + + )} + + )} +
+
+ ); +} + +/* ================================================================== */ +/* Wettkampf Quiz Page */ +/* ================================================================== */ + +function WettkampfQuizPage({ params, navigate }) { + const [questions, setQuestions] = useState([]); + const [allQuestionsRaw, setAllQuestionsRaw] = useState([]); + const [index, setIndex] = useState(0); + const [answers, setAnswers] = useState({}); // { questionId: { selected: [...], rowId: null } } + const [error, setError] = useState(''); + const [enlargedImg, setEnlargedImg] = useState(null); + const [shuffledAnswersMap, setShuffledAnswersMap] = useState({}); + const [saving, setSaving] = useState(false); + const [confirming, setConfirming] = useState(false); + const sessionId = useRef(params.continueSessionId || 'WK-' + Date.now().toString(36).toUpperCase()); + + // Type-aware random selection + const selectQuestions = useCallback((allQuestions, count) => { + const shuffled = shuffleArray(allQuestions); + + // Detect all unique types dynamically + const typeGroups = {}; + shuffled.forEach((q) => { + const typ = fieldVal(q['Typ'], 'MC'); + if (!typeGroups[typ]) typeGroups[typ] = []; + typeGroups[typ].push(q); + }); + + const typeKeys = Object.keys(typeGroups); + const selected = []; + const usedIds = new Set(); + + // If count >= number of types, pick one of each type first + if (typeKeys.length > 1 && count >= typeKeys.length) { + for (const typ of typeKeys) { + const q = typeGroups[typ][0]; + selected.push(q); + usedIds.add(q.id); + } + } + + // Fill remaining from shuffled pool + for (const q of shuffled) { + if (selected.length >= count) break; + if (!usedIds.has(q.id)) { + selected.push(q); + usedIds.add(q.id); + } + } + + return shuffleArray(selected); // Shuffle final order + }, []); + + useEffect(() => { + api.get(`/api/sets/${params.setId}/questions`).then((qs) => { + setAllQuestionsRaw(qs); + + if (params.priorAnswers && params.priorAnswers.length > 0) { + // Resuming: find the questions that were already answered + const priorIds = new Set(params.priorAnswers.map((a) => a.Frage_ID)); + const resumeQuestions = qs.filter((q) => priorIds.has(q.id)); + setQuestions(resumeQuestions); + + // Restore answers state + const restored = {}; + params.priorAnswers.forEach((a) => { + const ansStr = a.Antwort || ''; + restored[a.Frage_ID] = { + selected: ansStr ? ansStr.split(',').map((s) => s.trim()) : [], + rowId: a.rowId, + }; + }); + setAnswers(restored); + } else { + const picked = selectQuestions(qs, params.questionCount); + setQuestions(picked); + } + }).catch((e) => setError(e.message)); + }, []); + + // Build shuffled answers for each question + useEffect(() => { + if (questions.length === 0) return; + const map = {}; + questions.forEach((q) => { + const typ = fieldVal(q['Typ'], 'MC').toLowerCase(); + if (typ !== 'freitext') { + const antworten = []; + ['A', 'B', 'C', 'D'].forEach((k) => { if (q[`Antwort ${k}`]) antworten.push({ key: k, text: q[`Antwort ${k}`] }); }); + map[q.id] = shuffleArray(antworten); + } else { + map[q.id] = []; + } + }); + setShuffledAnswersMap(map); + }, [questions]); + + const q = questions[index]; + const answeredCount = Object.keys(answers).filter((qid) => { + const a = answers[qid]; + return a && a.selected && a.selected.length > 0 && (a.selected[0] !== ''); + }).length; + const allAnswered = answeredCount >= questions.length; + + const saveAnswer = async (questionId, selected) => { + const existing = answers[questionId]; + const answerText = selected.join(','); + + if (existing && existing.rowId) { + // Update existing answer + try { + await api.put('/api/results/update', { + sessionId: sessionId.current, + frageId: questionId, + nutzername: params.name, + antwort: answerText, + }); + } catch (e) { + console.error('Update error:', e); + } + } else { + // Create new answer + try { + setSaving(true); + const row = await api.post('/api/results/single', { + Nutzername: params.name, + Frage_ID: questionId, + Fragenset: params.setName, + Antwort: answerText, + Richtig: false, + Punkte: 0, + Session_ID: sessionId.current, + Modus: 'Wettkampf', + Zeitstempel: new Date().toISOString(), + }); + setAnswers((prev) => ({ + ...prev, + [questionId]: { selected, rowId: row.id }, + })); + setSaving(false); + return; + } catch (e) { + console.error('Save error:', e); + setSaving(false); + } + } + + setAnswers((prev) => ({ + ...prev, + [questionId]: { ...prev[questionId], selected }, + })); + }; + + const handleSelect = (questionId, selected) => { + setAnswers((prev) => ({ + ...prev, + [questionId]: { ...(prev[questionId] || {}), selected }, + })); + }; + + const submitCurrent = () => { + if (!q) return; + const currentAnswer = answers[q.id]; + if (currentAnswer && currentAnswer.selected && currentAnswer.selected.length > 0) { + saveAnswer(q.id, currentAnswer.selected); + } + // Move to next unanswered or stay + goToNextUnanswered(); + }; + + const goToNextUnanswered = () => { + // Find next unanswered starting from current index + for (let i = 1; i <= questions.length; i++) { + const nextIdx = (index + i) % questions.length; + const qId = questions[nextIdx].id; + const a = answers[qId]; + if (!a || !a.selected || a.selected.length === 0 || a.selected[0] === '') { + setIndex(nextIdx); + return; + } + } + // All answered; stay on current + }; + + const skipQuestion = () => { + // Just go to next question (wrapping) + const nextIdx = (index + 1) % questions.length; + setIndex(nextIdx); + }; + + const confirmResults = async () => { + setConfirming(true); + // Calculate scores for all questions + const finalResults = []; + questions.forEach((question) => { + const a = answers[question.id]; + if (!a) return; + + const typ = fieldVal(question['Typ'], 'MC').toLowerCase(); + const rawCorrect = (question['Richtige Antwort'] || '').trim(); + let ok = false; + + if (typ === 'freitext') { + ok = normalizeText(a.selected[0]) === normalizeText(rawCorrect); + } else { + // Resolve correct answers + const parts = rawCorrect.split(',').map((s) => s.trim()).filter(Boolean); + const validKeys = new Set(['A', 'B', 'C', 'D']); + const allAreKeys = parts.length > 0 && parts.every((p) => validKeys.has(p.toUpperCase())); + + let correctKeysResolved; + if (allAreKeys) { + correctKeysResolved = parts.map((p) => p.toUpperCase()); + } else { + correctKeysResolved = []; + for (const part of parts) { + const partLower = part.toLowerCase().trim(); + const allAntworten = []; + ['A', 'B', 'C', 'D'].forEach((k) => { + if (question[`Antwort ${k}`]) allAntworten.push({ key: k, text: question[`Antwort ${k}`] }); + }); + for (const ans of allAntworten) { + if (ans.text.toLowerCase().trim() === partLower) { + correctKeysResolved.push(ans.key); + break; + } + } + } + if (correctKeysResolved.length === 0) correctKeysResolved = parts.map((p) => p.toUpperCase()); + } + + const correctSet = new Set(correctKeysResolved); + const selectedSet = new Set(a.selected.map((s) => s.toUpperCase())); + ok = correctKeysResolved.length === selectedSet.size && correctKeysResolved.every((c) => selectedSet.has(c)); + } + + finalResults.push({ + rowId: a.rowId, + questionId: question.id, + richtig: ok, + punkte: ok ? 1000 : 0, + }); + }); + + // Save final scores to backend + try { + const rowsToUpdate = finalResults.filter((r) => r.rowId); + if (rowsToUpdate.length > 0) { + await api.put('/api/results/finalize', { results: rowsToUpdate }); + } + } catch (e) { + console.error('Finalize error:', e); + } + + navigate('wettkampf-result', { + results: finalResults, + questions: questions, + answers: answers, + setName: params.setName, + name: params.name, + allQuestionsRaw: allQuestionsRaw, + shuffledAnswersMap: shuffledAnswersMap, + }); + }; + + if (error) return
{error}
; + if (!questions.length) return
Lade Fragen...
; + + const currentAnswer = answers[q?.id]; + const currentSelected = currentAnswer?.selected || []; + const typ = fieldVal(q['Typ'], 'MC').toLowerCase(); + const isWF = typ === 'wahr/falsch'; + const currentShuffledAnswers = shuffledAnswersMap[q?.id] || []; + + const sanitized = { + frage: q['Frage'] || '(Kein Fragetext)', + typ: fieldVal(q['Typ'], 'MC'), + bild: q['Bild'] && Array.isArray(q['Bild']) && q['Bild'].length ? q['Bild'][0].url : null, + antworten: currentShuffledAnswers, + }; + + const hasCurrentAnswer = currentSelected.length > 0 && currentSelected[0] !== ''; + + return ( +
+ {/* Header */} +
+ { if (confirm('Wettkampf wirklich verlassen? Dein Fortschritt bleibt gespeichert.')) navigate('home'); }} label="Verlassen" /> + Frage {index + 1} / {questions.length} +
+ + {/* Progress bar */} +
+
+
+ + {/* Question navigator */} +
+ {questions.map((question, i) => { + const a = answers[question.id]; + const isAnswered = a && a.selected && a.selected.length > 0 && a.selected[0] !== ''; + const isCurrent = i === index; + let cls = 'w-9 h-9 rounded-lg text-sm font-bold transition-all '; + if (isCurrent) cls += 'bg-amber-500 text-white ring-2 ring-amber-300'; + else if (isAnswered) cls += 'bg-emerald-600 text-white'; + else cls += 'bg-gray-700 text-gray-400 hover:bg-gray-600'; + return ; + })} +
+ + {/* Status */} +
+ {answeredCount} + / {questions.length} beantwortet +
+ + {/* Question */} +

{sanitized.frage}

+ {sanitized.bild && setEnlargedImg(sanitized.bild)} />} + + {/* Answer area — no feedback, just selection */} + {typ === 'freitext' ? ( + handleSelect(q.id, [e.target.value])} autoFocus /> + ) : ( +
+ {sanitized.antworten.map((a) => { + const isSelected = currentSelected.includes(a.key); + const cls = `${ANSWER_COLORS[a.key]} ${isSelected ? 'selected' : ''} text-white font-bold py-4 px-6 rounded-xl text-lg cursor-pointer transition-all text-center`; + return ( + + ); + })} +
+ )} + + {/* Action buttons */} +
+ {hasCurrentAnswer && ( + + )} + +
+ + {/* Confirm button */} +
+ {allAnswered ? ( + + ) : ( + + )} +
+ + setEnlargedImg(null)} /> +
+ ); +} + +/* ================================================================== */ +/* Wettkampf Result Page */ +/* ================================================================== */ + +function WettkampfResultPage({ params, navigate }) { + const { results, questions, answers, setName, name, shuffledAnswersMap } = params; + const total = results.length; + const correct = results.filter((r) => r.richtig).length; + const pct = total > 0 ? Math.round((correct / total) * 100) : 0; + + const qMap = {}; + questions.forEach((q) => { qMap[q.id] = q; }); + + // Separate into wrong and right + const wrongResults = results.filter((r) => !r.richtig); + const rightResults = results.filter((r) => r.richtig); + + // Resolve correct answers for a question + const resolveCorrectKeys = (question) => { + const rawCorrect = (question['Richtige Antwort'] || '').trim(); + const typ = fieldVal(question['Typ'], 'MC').toLowerCase(); + if (typ === 'freitext') return { keys: [], text: rawCorrect }; + + const parts = rawCorrect.split(',').map((s) => s.trim()).filter(Boolean); + const validKeys = new Set(['A', 'B', 'C', 'D']); + const allAreKeys = parts.length > 0 && parts.every((p) => validKeys.has(p.toUpperCase())); + + if (allAreKeys) return { keys: parts.map((p) => p.toUpperCase()), text: null }; + + const resolved = []; + for (const part of parts) { + const partLower = part.toLowerCase().trim(); + ['A', 'B', 'C', 'D'].forEach((k) => { + if ((question[`Antwort ${k}`] || '').toLowerCase().trim() === partLower) resolved.push(k); + }); + } + return { keys: resolved.length > 0 ? resolved : parts.map((p) => p.toUpperCase()), text: null }; + }; + + const QuestionReview = ({ result }) => { + const question = qMap[result.questionId]; + if (!question) return null; + const typ = fieldVal(question['Typ'], 'MC').toLowerCase(); + const userAnswer = answers[result.questionId]; + const selected = userAnswer?.selected || []; + const { keys: correctKeys, text: correctText } = resolveCorrectKeys(question); + const shuffledAntworten = shuffledAnswersMap[question.id] || []; + + return ( +
+

{question['Frage']}

+ {question['Bild'] && Array.isArray(question['Bild']) && question['Bild'].length > 0 && ( + + )} + {typ === 'freitext' ? ( +
+
+ {result.richtig ? '✓ ' : '✗ '}{selected[0] || '(keine Antwort)'} +
+ {!result.richtig && correctText && ( +
✓ {correctText}
+ )} +
+ ) : ( +
+ {shuffledAntworten.map((a) => { + const isSelected = selected.includes(a.key); + const isCorrectAnswer = correctKeys.includes(a.key); + let cls = 'py-3 px-4 rounded-lg text-center font-bold '; + let icon = ''; + if (isCorrectAnswer) { cls += 'bg-green-600 text-white ring-2 ring-green-400'; icon = '✓ '; } + else if (isSelected) { cls += 'bg-red-700 text-white ring-2 ring-red-400'; icon = '✗ '; } + else { cls += 'bg-gray-700 text-gray-500 opacity-40'; } + return
{icon}{a.text}
; + })} +
+ )} +
+ ); + }; + + // Category breakdown + const byCategory = {}; + results.forEach((r) => { + const q = qMap[r.questionId]; + const cat = fieldVal(q?.['Kategorie'], 'Ohne Kategorie') || 'Ohne Kategorie'; + if (!byCategory[cat]) byCategory[cat] = { total: 0, correct: 0 }; + byCategory[cat].total++; + if (r.richtig) byCategory[cat].correct++; + }); + + return ( +
+ {/* Overall Score */} +

⚔️ Wettkampf-Ergebnis

+

{setName} — {name}

+
= 70 ? 'text-green-400' : pct >= 40 ? 'text-yellow-400' : 'text-red-400'}`}>{pct}%
+

{correct} / {total} richtig

+ + {/* Category breakdown */} + {Object.keys(byCategory).length > 1 && ( +
+

Nach Kategorie:

+ {Object.entries(byCategory).map(([cat, v]) => ( +
+ {cat} + = 0.7 ? 'text-green-400' : 'text-red-400'}>{v.correct}/{v.total} +
+ ))} +
+ )} + + {/* Wrong answers */} + {wrongResults.length > 0 && ( +
+

❌ Falsch beantwortet ({wrongResults.length})

+
+ {wrongResults.map((r) => )} +
+
+ )} + + {/* Right answers */} + {rightResults.length > 0 && ( +
+

✅ Richtig beantwortet ({rightResults.length})

+
+ {rightResults.map((r) => )} +
+
+ )} + +
+ + +
+
+ ); +} + /* ================================================================== */ /* My Results Page */ /* ================================================================== */ @@ -714,7 +1369,7 @@ function MyResultsPage({ navigate }) {
- {s.modus} + {s.modus} {s.fragenset}
{s.date ? new Date(s.date).toLocaleDateString('de') : ''} @@ -745,6 +1400,9 @@ function App() { case 'solo-select': return ; case 'solo-quiz': return ; case 'solo-result': return ; + case 'wettkampf-select': return ; + case 'wettkampf-quiz': return ; + case 'wettkampf-result': return ; case 'my-results': return ; default: return ; } diff --git a/server/baserow.js b/server/baserow.js index 683bdb1..e47d6a1 100644 --- a/server/baserow.js +++ b/server/baserow.js @@ -61,4 +61,12 @@ async function batchCreateRows(tableId, rows) { } } -module.exports = { listRows, createRow, batchCreateRows }; \ No newline at end of file +async function updateRow(tableId, rowId, data) { + console.log(`[baserow] Update row ${rowId} in table ${tableId}`); + return baserowFetch(`/database/rows/table/${tableId}/${rowId}/?user_field_names=true`, { + method: 'PATCH', + body: JSON.stringify(data), + }); +} + +module.exports = { listRows, createRow, batchCreateRows, updateRow }; \ No newline at end of file diff --git a/server/index.js b/server/index.js index acb10a6..b06d867 100644 --- a/server/index.js +++ b/server/index.js @@ -2,7 +2,7 @@ const express = require('express'); const http = require('http'); const path = require('path'); const { getEnvConfig, readConfig, writeConfig } = require('./config'); -const { listRows, createRow, batchCreateRows } = require('./baserow'); +const { listRows, createRow, batchCreateRows, updateRow } = require('./baserow'); const { setupWebSocket, getSessions } = require('./live-session'); const { extractFieldValue } = require('./quiz-logic'); @@ -106,19 +106,77 @@ app.get('/api/sets/:id/questions', async (req, res) => { } }); -/* ---- Solo: Save single answer immediately ---- */ +app.get('/api/sets/:id/count', async (req, res) => { + try { + const config = readConfig(); + const set = config.sets.find((s) => s.id === req.params.id); + if (!set) return res.status(404).json({ error: 'Fragenset nicht gefunden' }); + const rows = await listRows(set.tableId); + res.json({ count: rows.length }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +/* ---- Solo/Wettkampf: Save single answer immediately ---- */ app.post('/api/results/single', async (req, res) => { try { const config = readConfig(); if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' }); - await createRow(config.answersTableId, req.body); - res.json({ success: true }); + const row = await createRow(config.answersTableId, req.body); + res.json({ success: true, id: row.id }); } catch (e) { console.error('[api] Single save error:', e.message); res.status(500).json({ error: e.message }); } }); +/* ---- Wettkampf: Update existing answer ---- */ +app.put('/api/results/update', async (req, res) => { + try { + const config = readConfig(); + if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' }); + const { sessionId, frageId, nutzername, antwort } = req.body; + if (!sessionId || !frageId || !nutzername) return res.status(400).json({ error: 'sessionId, frageId und nutzername erforderlich' }); + + // Find the existing row + const rows = await listRows(config.answersTableId, { + Session_ID: sessionId, + Nutzername: nutzername, + }); + const existing = rows.find((r) => r['Frage_ID'] === frageId); + if (!existing) return res.status(404).json({ error: 'Antwort nicht gefunden' }); + + await updateRow(config.answersTableId, existing.id, { Antwort: antwort }); + res.json({ success: true }); + } catch (e) { + console.error('[api] Update answer error:', e.message); + res.status(500).json({ error: e.message }); + } +}); + +/* ---- Wettkampf: Finalize scores ---- */ +app.put('/api/results/finalize', async (req, res) => { + try { + const config = readConfig(); + if (!config.answersTableId) return res.status(400).json({ error: 'Antworten-Tabelle nicht konfiguriert' }); + const { results } = req.body; + if (!results || !results.length) return res.status(400).json({ error: 'Keine Ergebnisse' }); + + // results is array of { rowId, richtig, punkte } + for (const r of results) { + await updateRow(config.answersTableId, r.rowId, { + Richtig: r.richtig, + Punkte: r.punkte, + }); + } + res.json({ success: true }); + } catch (e) { + console.error('[api] Finalize error:', e.message); + res.status(500).json({ error: e.message }); + } +}); + /* ---- Solo: Check for incomplete session ---- */ app.get('/api/progress/:name/:setId', async (req, res) => { try { @@ -187,6 +245,72 @@ app.get('/api/progress/:name/:setId', async (req, res) => { } }); +/* ---- Wettkampf: Check for incomplete session ---- */ +app.get('/api/wettkampf-progress/:name/:setId', async (req, res) => { + try { + const config = readConfig(); + const set = config.sets.find((s) => s.id === req.params.setId); + if (!set) return res.status(404).json({ error: 'Set nicht gefunden' }); + if (!config.answersTableId) return res.json({ hasProgress: false }); + + const allAnswers = await listRows(config.answersTableId, { + Nutzername: req.params.name, + Fragenset: set.name, + }); + + // Filter to Wettkampf only + const wkAnswers = allAnswers.filter((a) => { + return extractFieldValue(a['Modus'], '') === 'Wettkampf'; + }); + + // Group by Session_ID + const bySession = {}; + wkAnswers.forEach((a) => { + const sid = a['Session_ID'] || ''; + if (!bySession[sid]) bySession[sid] = []; + bySession[sid].push(a); + }); + + // Find the most recent session where NOT all answers are finalized (Punkte is still 0 for all) + let best = null; + for (const [sid, answers] of Object.entries(bySession)) { + // A session is "incomplete" if all Punkte are 0 and all Richtig are false (not yet confirmed) + const allUnscored = answers.every((a) => { + const punkte = Number(a['Punkte']) || 0; + const richtig = a['Richtig']; + return punkte === 0 && (richtig === false || richtig === 'false' || !richtig); + }); + if (allUnscored && answers.length > 0) { + const latest = answers.reduce((max, a) => { + const ts = a['Zeitstempel'] || ''; + return ts > max ? ts : max; + }, ''); + if (!best || latest > best.latest) { + best = { sid, answers, latest }; + } + } + } + + if (best) { + res.json({ + hasProgress: true, + sessionId: best.sid, + answers: best.answers.map((a) => ({ + rowId: a.id, + Frage_ID: a['Frage_ID'], + Antwort: a['Antwort'] || '', + })), + totalAnswered: best.answers.length, + }); + } else { + res.json({ hasProgress: false }); + } + } catch (e) { + console.error('[api] Wettkampf progress error:', e.message); + res.status(500).json({ error: e.message }); + } +}); + /* ---- Batch save (still used by live sessions) ---- */ app.post('/api/results', async (req, res) => { try {