import { useEffect, useMemo, useState } from 'react'; import { fetchEntries, fetchEntry } from '../api'; import type { EntryDetail, EntryItems, EntrySummary, QuizQuestionWithEntry } from '../types'; import VideoPlayer from './VideoPlayer'; type Mode = 'all' | 'selected' | 'single'; const TOTAL_QUESTIONS = 10; interface QuizRunnerProps { defaultMode?: Mode; defaultEntryId?: string; autoStart?: boolean; } interface TargetHit { group: string; item: Record; } function shuffle(list: T[]) { const copy = [...list]; for (let i = copy.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); [copy[i], copy[j]] = [copy[j], copy[i]]; } return copy; } const normalize = (val: any) => (val === undefined || val === null ? '' : String(val).trim()); function resolveTargets(question: QuizQuestionWithEntry): TargetHit[] { const targetIds = new Set((question.targets || []).map((t) => normalize(t))); const groups: { label: string; items: any[] }[] = [ { label: 'Grammar', items: question.items?.grammar || [] }, { label: 'Vocabulary', items: question.items?.vocab || [] }, { label: 'Key Phrases', items: question.items?.key_phrases || [] }, { label: 'Conversation', items: question.items?.conversation || [] }, ]; const found: TargetHit[] = []; groups.forEach(({ label, items }) => { items.forEach((item) => { if (item?.id && targetIds.has(normalize(item.id))) { found.push({ group: label, item }); } }); }); return found; } function deriveCorrectText(question: QuizQuestionWithEntry) { const options: any[] = Array.isArray(question.payload?.options) ? question.payload?.options : []; if (typeof question.answer?.correct_index === 'number' && options[question.answer.correct_index]) { return options[question.answer.correct_index]; } if (question.answer?.correct_text) return question.answer.correct_text; if (question.payload?.blanked) return question.payload.blanked; const pairs = Array.isArray(question.payload?.pairs) ? question.payload.pairs : []; if (pairs.length) { return pairs.map((p: any) => `${p.left} → ${p.right}`).join(' | '); } return ''; } function checkClozeAnswer(question: QuizQuestionWithEntry, response: string) { if (!response) return false; const expected = [question.answer?.correct_text, question.answer?.correct, question.payload?.blanked].filter(Boolean).map(normalize); const answer = normalize(response); return expected.some((val) => val === answer || val.toLowerCase() === answer.toLowerCase()); } function checkMatchAnswer(question: QuizQuestionWithEntry, response: Record | null) { const pairs: any[] = Array.isArray(question.payload?.pairs) ? question.payload.pairs : []; if (!pairs.length) return false; return pairs.every((pair, idx) => { const expected = normalize(pair.right); const user = normalize(response?.[idx]); return expected === user; }); } function checkMcAnswer(question: QuizQuestionWithEntry, response: number | null) { if (typeof response !== 'number') return false; if (typeof question.answer?.correct_index !== 'number') return false; return response === question.answer.correct_index; } function QuestionRenderer({ question, response, onChange, showResult, lastCorrect, }: { question: QuizQuestionWithEntry; response: any; onChange: (val: any) => void; showResult: boolean; lastCorrect: boolean; canSubmit: boolean; }) { const payload = question.payload || {}; const type = question.type || ''; const correctIndex = typeof question.answer?.correct_index === 'number' ? question.answer.correct_index : null; if (type === 'cloze') { const sentence = payload.sentence_jp || payload.sentence || ''; return (
{sentence &&
{sentence.replace(payload.blanked || '', '____')}
} onChange(e.target.value)} disabled={showResult} /> {Array.isArray(payload.options) && payload.options.length > 0 && (
Hints: {payload.options.join(' • ')}
)}
); } if (type === 'match') { const pairs: any[] = Array.isArray(payload.pairs) ? payload.pairs : []; const rightOptions = useMemo( () => shuffle(pairs.map((p) => p.right).filter(Boolean)), [question.id, question.entryId] ); return (
{pairs.map((pair, idx) => (
{pair.left}
))}
); } const options: any[] = Array.isArray(payload.options) ? payload.options : []; if (!options.length) { return
No options provided for this question.
; } return (
{options.map((option, idx) => ( ))}
); } import IgMetaBlock from './IgMetaBlock'; function ExplanationPanel({ question, targets }: { question: QuizQuestionWithEntry; targets: TargetHit[] }) { return (

Explanation

{targets.length ? (
{targets.map(({ group, item }) => (
{item.jp || item.pattern || item.id} {item.kana &&
{item.kana}
}
{item.meaning_en || item.meaning || item.en || item.when_to_use_en || item.note_en}
{item.use_note_en &&
{item.use_note_en}
} {item.when_to_use_en &&
{item.when_to_use_en}
} {item.register && {item.register}} {(item.example || item.example_en || item.example_jp) && (
Example
{item.example?.jp || item.example_jp}
{item.example?.kana || item.example_kana}
{item.example?.en || item.example_en}
)}
{group}
))}
) : (

No linked study items were found for this question.

)} {question.ig_meta && }
); } export default function QuizRunner({ defaultMode = 'all', defaultEntryId, autoStart }: QuizRunnerProps) { const [entries, setEntries] = useState([]); const [loadingEntries, setLoadingEntries] = useState(true); const [mode] = useState(defaultMode); const [selectedIds] = useState(defaultEntryId ? [defaultEntryId] : []); const [questions, setQuestions] = useState([]); const [history, setHistory] = useState< { response: any; correct: boolean; skipped: boolean; showExplanation: boolean }[] >([]); const [status, setStatus] = useState<'setup' | 'loading' | 'running' | 'finished'>('setup'); const [currentIndex, setCurrentIndex] = useState(0); const [score, setScore] = useState(0); const [response, setResponse] = useState(null); const [showResult, setShowResult] = useState(false); const [lastCorrect, setLastCorrect] = useState(false); const [lastSkipped, setLastSkipped] = useState(false); const [showExplanation, setShowExplanation] = useState(false); const [error, setError] = useState(null); useEffect(() => { setLoadingEntries(true); fetchEntries() .then((data) => setEntries(data)) .catch(() => setError('Could not load entries.')) .finally(() => setLoadingEntries(false)); }, []); useEffect(() => { if (autoStart && status === 'setup' && entries.length > 0) { startQuiz(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoStart, defaultEntryId, entries.length, status]); const currentQuestion = useMemo(() => questions[currentIndex], [questions, currentIndex]); const resetQuestionState = () => { setResponse(null); setShowResult(false); setLastCorrect(false); setLastSkipped(false); setShowExplanation(false); }; const startQuiz = async () => { const ids: string[] = mode === 'all' ? entries.map((e) => e.id) : selectedIds.length ? selectedIds.slice(0, 1) : entries.map((e) => e.id); if (!ids.length) { setError('Pick at least one entry to quiz on.'); return; } setError(null); setStatus('loading'); try { const uniqueIds = Array.from(new Set(ids)); const details: EntryDetail[] = await Promise.all(uniqueIds.map((id) => fetchEntry(id))); const pool: QuizQuestionWithEntry[] = details.flatMap((entry) => { const safeItems: EntryItems = entry.items || { grammar: [], vocab: [], conversation: [], key_phrases: [] }; return (entry.quiz || []).map((q) => ({ ...q, entryId: entry.id, entryTitle: entry.title, items: safeItems, video_url: entry.video_url, ig_meta: entry.ig_meta, targets: q.targets || [], type: q.type || 'unknown', payload: q.payload || {}, answer: q.answer || {}, })); }); if (!pool.length) { setError('No quiz questions found in the selected entries.'); setStatus('setup'); return; } const chosen = shuffle(pool).slice(0, Math.min(TOTAL_QUESTIONS, pool.length)); setQuestions(chosen); setCurrentIndex(0); setScore(0); resetQuestionState(); setHistory(Array(chosen.length).fill(null)); setStatus('running'); } catch (err: any) { setError(err?.message || 'Could not start quiz.'); setStatus('setup'); } }; const handleSubmit = (skip = false) => { if (!currentQuestion) return; if (history[currentIndex]) { // already answered; keep locked setShowResult(true); setShowExplanation(history[currentIndex]?.showExplanation || false); setLastCorrect(history[currentIndex]?.correct || false); setLastSkipped(history[currentIndex]?.skipped || false); setResponse(history[currentIndex]?.response ?? null); return; } let correct = false; if (!skip) { if ((currentQuestion.type || '').startsWith('mc') || currentQuestion.type === 'choose_best_reply') { correct = checkMcAnswer(currentQuestion, response); } else if (currentQuestion.type === 'cloze') { correct = checkClozeAnswer(currentQuestion, response); } else if (currentQuestion.type === 'match') { correct = checkMatchAnswer(currentQuestion, response); } else if (typeof currentQuestion.answer?.correct_index === 'number') { correct = checkMcAnswer(currentQuestion, response); } } if (correct) { setScore((s) => s + 1); } setLastCorrect(correct); setLastSkipped(skip); setShowResult(true); setShowExplanation(!correct); setHistory((prev) => { const next = [...prev]; next[currentIndex] = { response, correct, skipped: skip, showExplanation: !correct }; return next; }); }; const goNext = () => { if (currentIndex + 1 >= questions.length) { setStatus('finished'); } else { setCurrentIndex((idx) => idx + 1); } }; const goPrev = () => { if (currentIndex > 0) { setCurrentIndex((idx) => Math.max(0, idx - 1)); } }; useEffect(() => { if (status !== 'running') return; const saved = history[currentIndex]; if (saved) { setResponse(saved.response); setShowResult(true); setLastCorrect(saved.correct); setLastSkipped(saved.skipped); setShowExplanation(saved.showExplanation); } else { resetQuestionState(); } }, [currentIndex, history, status]); useEffect(() => { if (!showResult) return; setHistory((prev) => { const next = [...prev]; const existing = next[currentIndex]; if (existing) { next[currentIndex] = { ...existing, showExplanation }; } return next; }); }, [showExplanation, showResult, currentIndex]); if (loadingEntries) { return
Loading quiz setup…
; } if (status === 'setup') { return (

Building your quiz…

Preparing 10 questions from {mode === 'single' ? 'this entry' : 'all entries'}.

{error &&
{error}
} {!autoStart && ( )}
); } if (status === 'loading') { return
Building your quiz…
; } if (status === 'finished') { return (

Nice work!

You scored {score} out of {questions.length}.

); } if (!currentQuestion) { return
No questions available.
; } const targets = resolveTargets(currentQuestion); const correctText = deriveCorrectText(currentQuestion); const shouldShowExplanation = showResult && (!lastCorrect || showExplanation); return (

{currentQuestion.entryTitle}

{currentQuestion.prompt_en || 'Answer the prompt'}

{currentIndex + 1} / {questions.length}
Score: {score}
0) || (currentQuestion.type === 'match' && response && Object.values(response).every((v) => v)) || ((currentQuestion.type || '').startsWith('mc') && typeof response === 'number') || (currentQuestion.type === 'choose_best_reply' && typeof response === 'number') || (!currentQuestion.type && typeof response === 'number') )} /> {showResult && (
{lastCorrect ? 'Correct!' : lastSkipped ? 'Answer:' : 'Not quite.'} {correctText && (
{lastSkipped ? correctText : `Answer: ${correctText}`}
)}
)}
{currentIndex > 0 && ( )} {!showResult ? ( <> ) : ( <> {lastCorrect && !showExplanation && ( )} )}
{shouldShowExplanation && }
); }