Implement Wettkampf mode with random type-aware question selection and answer persistence

This commit is contained in:
2026-04-26 11:01:58 +02:00
parent c1b93e3ab6
commit 7b886c4355
3 changed files with 797 additions and 7 deletions
+660 -2
View File
@@ -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 }) {
<h1 className="text-5xl font-extrabold mb-2 mt-4 fire-gradient">Quizalarm</h1>
<p className="text-gray-400 mb-10 text-lg">Lernen. Quizzen. Wissen.</p>
<div className="w-full max-w-sm space-y-4">
<button onClick={() => navigate('join')} className="w-full py-4 bg-red-600 hover:bg-red-700 rounded-xl text-xl font-bold transition-all shadow-lg shadow-red-900/30">🎮 Live-Quiz beitreten</button>
<button onClick={() => navigate('wettkampf-select')} className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 rounded-xl text-xl font-bold transition-all shadow-lg shadow-emerald-900/30"> Wettkampf</button>
<button onClick={() => navigate('solo-select')} className="w-full py-4 bg-amber-600 hover:bg-amber-700 rounded-xl text-xl font-bold transition-all shadow-lg shadow-amber-900/30">📚 Solo-Lernen</button>
<button onClick={() => navigate('join')} className="w-full py-4 bg-red-600 hover:bg-red-700 rounded-xl text-xl font-bold transition-all shadow-lg shadow-red-900/30">🎮 Live-Quiz beitreten</button>
<button onClick={() => navigate('my-results')} className="w-full py-4 bg-gray-700 hover:bg-gray-600 rounded-xl text-xl font-bold transition-all">📊 Meine Ergebnisse</button>
</div>
<a href="/admin" className="mt-8 text-gray-500 hover:text-gray-300 text-sm">Admin-Bereich </a>
@@ -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 (
<div className="min-h-screen flex flex-col items-center p-6 pt-12">
<div className="w-full max-w-md"><BackButton onClick={() => navigate('home')} label="Zurück" /></div>
<h2 className="text-3xl font-bold mb-6 mt-2"> Wettkampf</h2>
<div className="w-full max-w-md space-y-4">
<input className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-xl border-2 border-gray-700 focus:border-emerald-500 outline-none" placeholder="Dein Name" value={name} onChange={(e) => setName(e.target.value)} />
{progressDialog && (
<div className="bg-gray-800 rounded-xl p-5 border-2 border-emerald-500 space-y-3">
<h3 className="font-bold text-lg text-emerald-400">📌 Gespeicherter Fortschritt</h3>
<p>{selectedSet.name}: <strong>{progressDialog.totalAnswered}</strong> Fragen beantwortet</p>
<div className="flex gap-3">
<button onClick={continueSession} className="flex-1 py-3 bg-emerald-600 hover:bg-emerald-700 rounded-xl font-bold">Weitermachen</button>
<button onClick={startFresh} className="flex-1 py-3 bg-gray-600 hover:bg-gray-500 rounded-xl font-bold">Neue Runde</button>
</div>
</div>
)}
{!progressDialog && (
<>
{!selectedSet ? (
<>
<h3 className="text-lg font-semibold mt-4">Fragenset wählen:</h3>
{sets.length === 0 && <p className="text-gray-500">Keine Fragensets konfiguriert.</p>}
{sets.map((s) => (
<button key={s.id} onClick={() => selectSet(s)}
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 rounded-xl text-lg font-bold transition-all shadow-lg shadow-emerald-900/30">
{s.name}
</button>
))}
</>
) : (
<>
<div className="bg-gray-800 rounded-xl p-4 flex justify-between items-center">
<div>
<span className="text-sm text-gray-400">Fragenset:</span>
<p className="font-bold text-emerald-400">{selectedSet.name}</p>
</div>
<button onClick={() => { setSelectedSet(null); setTotalQuestions(null); setQuestionCount(''); }} className="text-sm text-gray-400 hover:text-white">Ändern</button>
</div>
{totalQuestions !== null && (
<div className="space-y-2">
<label className="block text-sm text-gray-400">Anzahl Fragen (max. {totalQuestions})</label>
<input type="number" min="1" max={totalQuestions}
className="w-full p-4 rounded-xl bg-gray-800 text-white text-center text-xl border-2 border-gray-700 focus:border-emerald-500 outline-none"
placeholder={`1 ${totalQuestions}`}
value={questionCount}
onChange={(e) => setQuestionCount(e.target.value)} />
</div>
)}
{error && <p className="text-red-400">{error}</p>}
<button onClick={startWettkampf} disabled={checking || !questionCount}
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-40 rounded-xl text-xl font-bold transition-all shadow-lg shadow-emerald-900/30">
{checking ? 'Prüfe...' : '⚔️ Wettkampf starten'}
</button>
</>
)}
</>
)}
</div>
</div>
);
}
/* ================================================================== */
/* 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 <div className="min-h-screen flex items-center justify-center text-red-400 text-xl p-6 text-center">{error}</div>;
if (!questions.length) return <div className="min-h-screen flex items-center justify-center animate-pulse text-xl text-emerald-400">Lade Fragen...</div>;
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 (
<div className="min-h-screen flex flex-col p-4 max-w-2xl mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-2">
<BackButton onClick={() => { if (confirm('Wettkampf wirklich verlassen? Dein Fortschritt bleibt gespeichert.')) navigate('home'); }} label="Verlassen" />
<span className="text-sm text-gray-400">Frage {index + 1} / {questions.length}</span>
</div>
{/* Progress bar */}
<div className="w-full bg-gray-700 rounded-full h-2 mb-3">
<div className="bg-emerald-500 h-full rounded-full transition-all" style={{ width: `${(answeredCount / questions.length) * 100}%` }} />
</div>
{/* Question navigator */}
<div className="flex flex-wrap gap-1.5 mb-4 justify-center">
{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 <button key={question.id} onClick={() => setIndex(i)} className={cls}>{i + 1}</button>;
})}
</div>
{/* Status */}
<div className="text-center text-sm mb-3">
<span className="text-emerald-400 font-semibold">{answeredCount}</span>
<span className="text-gray-500"> / {questions.length} beantwortet</span>
</div>
{/* Question */}
<h2 className="text-xl font-bold mb-4 text-center">{sanitized.frage}</h2>
{sanitized.bild && <img src={sanitized.bild} className="max-h-48 mx-auto rounded-lg mb-4 cursor-pointer" alt="" onClick={() => setEnlargedImg(sanitized.bild)} />}
{/* Answer area — no feedback, just selection */}
{typ === 'freitext' ? (
<input type="text" className="w-full p-4 rounded-xl bg-gray-700 text-white text-xl text-center border-2 border-gray-600 focus:border-emerald-500 outline-none"
placeholder="Deine Antwort..." value={currentSelected[0] || ''}
onChange={(e) => handleSelect(q.id, [e.target.value])} autoFocus />
) : (
<div className={`grid gap-3 grid-cols-1 sm:grid-cols-2`}>
{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 (
<button key={a.key} className={cls}
onClick={() => {
if (isWF) handleSelect(q.id, [a.key]);
else handleSelect(q.id, isSelected ? currentSelected.filter((s) => s !== a.key) : [...currentSelected, a.key]);
}}>
{a.text}
</button>
);
})}
</div>
)}
{/* Action buttons */}
<div className="mt-4 space-y-3">
{hasCurrentAnswer && (
<button onClick={submitCurrent} disabled={saving}
className="w-full py-3 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 rounded-xl text-lg font-bold shadow-lg shadow-emerald-900/30">
{saving ? 'Speichere...' : 'Antwort speichern →'}
</button>
)}
<button onClick={skipQuestion}
className="w-full py-3 bg-gray-700 hover:bg-gray-600 rounded-xl text-lg font-bold">
Überspringen
</button>
</div>
{/* Confirm button */}
<div className="mt-6 border-t border-gray-700 pt-4">
{allAnswered ? (
<button onClick={confirmResults} disabled={confirming}
className="w-full py-4 bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-xl text-xl font-bold shadow-lg shadow-red-900/30 animate-pulse">
{confirming ? 'Werte aus...' : '⚔️ Ergebnis bestätigen'}
</button>
) : (
<button disabled className="w-full py-4 bg-gray-800 opacity-40 rounded-xl text-xl font-bold cursor-not-allowed">
Noch {questions.length - answeredCount} Fragen offen
</button>
)}
</div>
<ImageModal src={enlargedImg} onClose={() => setEnlargedImg(null)} />
</div>
);
}
/* ================================================================== */
/* 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 (
<div className={`bg-gray-800 rounded-xl p-4 border-l-4 ${result.richtig ? 'border-green-500' : 'border-red-500'}`}>
<h4 className="font-bold mb-2">{question['Frage']}</h4>
{question['Bild'] && Array.isArray(question['Bild']) && question['Bild'].length > 0 && (
<img src={question['Bild'][0].url} className="max-h-32 rounded-lg mb-2" alt="" />
)}
{typ === 'freitext' ? (
<div className="space-y-2">
<div className={`p-3 rounded-lg text-center font-bold ${result.richtig ? 'bg-green-600 ring-2 ring-green-400' : 'bg-red-700 ring-2 ring-red-400'}`}>
{result.richtig ? '✓ ' : '✗ '}{selected[0] || '(keine Antwort)'}
</div>
{!result.richtig && correctText && (
<div className="p-3 rounded-lg text-center font-bold bg-green-600 ring-2 ring-green-400"> {correctText}</div>
)}
</div>
) : (
<div className="grid gap-2 grid-cols-1 sm:grid-cols-2">
{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 <div key={a.key} className={cls}>{icon}{a.text}</div>;
})}
</div>
)}
</div>
);
};
// 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 (
<div className="min-h-screen flex flex-col items-center p-6 pt-12">
{/* Overall Score */}
<h2 className="text-3xl font-bold mb-2"> Wettkampf-Ergebnis</h2>
<p className="text-gray-400 mb-4">{setName} {name}</p>
<div className={`text-6xl font-extrabold mb-2 ${pct >= 70 ? 'text-green-400' : pct >= 40 ? 'text-yellow-400' : 'text-red-400'}`}>{pct}%</div>
<p className="text-xl mb-6">{correct} / {total} richtig</p>
{/* Category breakdown */}
{Object.keys(byCategory).length > 1 && (
<div className="w-full max-w-md bg-gray-800 rounded-xl p-4 mb-6">
<h3 className="font-bold mb-2">Nach Kategorie:</h3>
{Object.entries(byCategory).map(([cat, v]) => (
<div key={cat} className="flex justify-between py-1 border-b border-gray-700 last:border-0">
<span>{cat}</span>
<span className={v.correct / v.total >= 0.7 ? 'text-green-400' : 'text-red-400'}>{v.correct}/{v.total}</span>
</div>
))}
</div>
)}
{/* Wrong answers */}
{wrongResults.length > 0 && (
<div className="w-full max-w-md mb-6">
<h3 className="text-xl font-bold mb-3 text-red-400"> Falsch beantwortet ({wrongResults.length})</h3>
<div className="space-y-3">
{wrongResults.map((r) => <QuestionReview key={r.questionId} result={r} />)}
</div>
</div>
)}
{/* Right answers */}
{rightResults.length > 0 && (
<div className="w-full max-w-md mb-6">
<h3 className="text-xl font-bold mb-3 text-green-400"> Richtig beantwortet ({rightResults.length})</h3>
<div className="space-y-3">
{rightResults.map((r) => <QuestionReview key={r.questionId} result={r} />)}
</div>
</div>
)}
<div className="w-full max-w-md space-y-3">
<button onClick={() => navigate('wettkampf-select')} className="w-full py-3 bg-emerald-600 hover:bg-emerald-700 rounded-xl font-bold shadow-lg shadow-emerald-900/30">Nochmal spielen</button>
<button onClick={() => navigate('home')} className="w-full py-3 bg-gray-700 hover:bg-gray-600 rounded-xl font-bold">Zur Startseite</button>
</div>
</div>
);
}
/* ================================================================== */
/* My Results Page */
/* ================================================================== */
@@ -714,7 +1369,7 @@ function MyResultsPage({ navigate }) {
<div key={sid} className="bg-gray-800 rounded-xl p-4">
<div className="flex justify-between items-start mb-2">
<div>
<span className={`text-xs px-2 py-0.5 rounded-full text-white ${s.modus === 'Live' ? 'bg-red-600' : 'bg-amber-600'}`}>{s.modus}</span>
<span className={`text-xs px-2 py-0.5 rounded-full text-white ${s.modus === 'Live' ? 'bg-red-600' : s.modus === 'Wettkampf' ? 'bg-emerald-600' : 'bg-amber-600'}`}>{s.modus}</span>
<span className="ml-2 font-semibold">{s.fragenset}</span>
</div>
<span className="text-sm text-gray-400">{s.date ? new Date(s.date).toLocaleDateString('de') : ''}</span>
@@ -745,6 +1400,9 @@ function App() {
case 'solo-select': return <SoloSelectPage navigate={navigate} />;
case 'solo-quiz': return <SoloQuizPage params={params} navigate={navigate} />;
case 'solo-result': return <SoloResultPage params={params} navigate={navigate} />;
case 'wettkampf-select': return <WettkampfSelectPage navigate={navigate} />;
case 'wettkampf-quiz': return <WettkampfQuizPage params={params} navigate={navigate} />;
case 'wettkampf-result': return <WettkampfResultPage params={params} navigate={navigate} />;
case 'my-results': return <MyResultsPage navigate={navigate} />;
default: return <HomePage navigate={navigate} />;
}