diff --git a/client/src/App.tsx b/client/src/App.tsx index 3896413..b5019d1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { useAuth } from './hooks/useAuth' import { LoginPage } from './pages/LoginPage' import { OnboardingPage } from './pages/OnboardingPage' +import { GamePage } from './pages/GamePage' import './index.css' type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin' @@ -51,15 +52,8 @@ export default function App() { {route === 'onboarding' && token && ( setRoute('game')} /> )} - {route === 'game' && ( -
-

Practice

-

Hello, {profile?.display_name || profile?.email || 'Adventurer'}!

-

Game loop coming in Task 11.

- -
+ {route === 'game' && token && ( + { logout(); setRoute('login') }} /> )} {route === 'admin' && (
diff --git a/client/src/components/FlashArea.tsx b/client/src/components/FlashArea.tsx new file mode 100644 index 0000000..b6e793d --- /dev/null +++ b/client/src/components/FlashArea.tsx @@ -0,0 +1,15 @@ +type FlashAreaProps = { signal: 'dot' | 'dash' | null } + +export function FlashArea({ signal }: FlashAreaProps) { + const active = signal !== null + return ( +
+ ) +} diff --git a/client/src/components/PracticeCard.tsx b/client/src/components/PracticeCard.tsx new file mode 100644 index 0000000..8456dfd --- /dev/null +++ b/client/src/components/PracticeCard.tsx @@ -0,0 +1,106 @@ +import { useState, useEffect, useRef } from 'react' +import { MORSE } from '../lib/morse' +import { FlashArea } from './FlashArea' + +type PracticeCardProps = { + challenge: string // the letter or word to guess + signal: 'dot' | 'dash' | null + isPlaying: boolean + onPlay: () => void + onAnswer: (answer: string) => void // called when user submits + result: 'correct' | 'incorrect' | null + mnemonic: string | null +} + +export function PracticeCard({ + challenge, signal, isPlaying, onPlay, onAnswer, result, mnemonic +}: PracticeCardProps) { + const [answer, setAnswer] = useState('') + const inputRef = useRef(null) + + // Clear answer on new challenge (when result goes from non-null back to null) + useEffect(() => { + if (result === null) { + setAnswer('') + inputRef.current?.focus() + } + }, [result, challenge]) + + function submit() { + if (!answer.trim()) return + onAnswer(answer.trim().toUpperCase()) + } + + const morseDisplay = MORSE[challenge.toUpperCase()] + ? challenge.toUpperCase().split('').map(c => MORSE[c] ?? '').join(' ') + : '' + + return ( +
+ + +
+ +
+ + {/* Show morse pattern hint */} + {morseDisplay && ( +
+ {morseDisplay.split('').map(c => c === '.' ? '·' : c === '-' ? '—' : ' ').join('')} +
+ )} + + {/* Answer input */} +
+ setAnswer(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') submit() }} + placeholder="Type your answer..." + disabled={result === 'correct'} + style={{ flex: 1 }} + /> + +
+ + {/* Result feedback */} + {result === 'correct' && ( +
+ Correct! The answer was: {challenge.toUpperCase()} + {mnemonic && ( +

+ Your mnemonic: "{mnemonic}" +

+ )} +
+ )} + {result === 'incorrect' && ( +
+ Try again, adventurer! +
+ )} +
+ ) +} diff --git a/client/src/components/ScoreBar.tsx b/client/src/components/ScoreBar.tsx new file mode 100644 index 0000000..c397b2d --- /dev/null +++ b/client/src/components/ScoreBar.tsx @@ -0,0 +1,27 @@ +type ScoreBarProps = { + score: number + streak: number + level: number +} + +const LEVEL_NAMES: Record = { 1: 'Novice', 2: 'Operator' } + +export function ScoreBar({ score, streak, level }: ScoreBarProps) { + return ( +
+ {LEVEL_NAMES[level] ?? `Level ${level}`} + Score: {score} + Streak: = 5 ? 'var(--color-gold)' : 'inherit' }}>{streak} +
+ ) +} diff --git a/client/src/hooks/useMorseAudio.ts b/client/src/hooks/useMorseAudio.ts new file mode 100644 index 0000000..17f1520 --- /dev/null +++ b/client/src/hooks/useMorseAudio.ts @@ -0,0 +1,38 @@ +import { useRef, useState, useCallback } from 'react' +import { MorseAudioEngine } from '../lib/audio' +import { DEFAULT_WPM } from '../lib/morse' + +type SignalType = 'dot' | 'dash' | null + +export function useMorseAudio() { + const engineRef = useRef(null) + const [isPlaying, setIsPlaying] = useState(false) + const [currentSignal, setCurrentSignal] = useState(null) + + function getEngine() { + if (!engineRef.current) { + const engine = new MorseAudioEngine() + engine.on('signal-start', ({ type }: { type: 'dot' | 'dash' }) => setCurrentSignal(type)) + engine.on('signal-end', () => setCurrentSignal(null)) + engine.on('play-end', () => { setIsPlaying(false); setCurrentSignal(null) }) + engineRef.current = engine + } + return engineRef.current + } + + const play = useCallback((text: string) => { + const engine = getEngine() + engine.stop() + setIsPlaying(true) + setCurrentSignal(null) + engine.play(text, DEFAULT_WPM) + }, []) + + const stop = useCallback(() => { + engineRef.current?.stop() + setIsPlaying(false) + setCurrentSignal(null) + }, []) + + return { play, stop, isPlaying, currentSignal } +} diff --git a/client/src/hooks/useProgress.ts b/client/src/hooks/useProgress.ts new file mode 100644 index 0000000..8700d35 --- /dev/null +++ b/client/src/hooks/useProgress.ts @@ -0,0 +1,58 @@ +import { useState, useEffect, useCallback } from 'react' + +type Progress = { + level: number + score: number + streak: number + best_streak: number + total_correct: number + total_attempts: number +} + +type LetterStats = Record + +export function useProgress(token: string | null) { + const [progress, setProgress] = useState(null) + const [letterStats, setLetterStats] = useState({}) + const [mnemonics, setMnemonics] = useState>({}) + const [loading, setLoading] = useState(true) + + useEffect(() => { + if (!token) return + const headers = { Authorization: `Bearer ${token}` } + Promise.all([ + fetch('/api/progress', { headers }).then(r => r.json()), + fetch('/api/mnemonics', { headers }).then(r => r.json()), + ]) + .then(([prog, mnems]) => { + setProgress(prog) + setMnemonics(mnems) + }) + .finally(() => setLoading(false)) + }, [token]) + + const recordAnswer = useCallback(async (letter: string, correct: boolean): Promise => { + const headers = token ? { Authorization: `Bearer ${token}` } : {} + const r = await fetch('/api/progress/answer', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ letter, correct }), + }) + const updated: Progress = await r.json() + setProgress(updated) + // Update local letter stats + setLetterStats(prev => { + const stat = prev[letter.toUpperCase()] ?? { correct: 0, attempts: 0 } + return { + ...prev, + [letter.toUpperCase()]: { + correct: stat.correct + (correct ? 1 : 0), + attempts: stat.attempts + 1, + }, + } + }) + return updated + }, [token]) + + return { progress, letterStats, mnemonics, loading, recordAnswer } +} diff --git a/client/src/pages/GamePage.tsx b/client/src/pages/GamePage.tsx new file mode 100644 index 0000000..97d87ae --- /dev/null +++ b/client/src/pages/GamePage.tsx @@ -0,0 +1,130 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { pickChallenge, NOVICE_LETTERS, OPERATOR_WORDS } from '../lib/morse' +import { useMorseAudio } from '../hooks/useMorseAudio' +import { useProgress } from '../hooks/useProgress' +import { ScoreBar } from '../components/ScoreBar' +import { PracticeCard } from '../components/PracticeCard' + +type GamePageProps = { + token: string + onLogout: () => void +} + +export function GamePage({ token, onLogout }: GamePageProps) { + const audio = useMorseAudio() + const { progress, letterStats, mnemonics, loading, recordAnswer } = useProgress(token) + const [challenge, setChallenge] = useState(null) + const [result, setResult] = useState<'correct' | 'incorrect' | null>(null) + const [showLevelUp, setShowLevelUp] = useState(false) + const recentHistoryRef = useRef([]) + const incorrectRecordedRef = useRef(false) + + // Pick a new challenge based on current level + const nextChallenge = useCallback(() => { + const level = progress?.level ?? 1 + const pool = level >= 2 ? OPERATOR_WORDS : NOVICE_LETTERS + const c = pickChallenge(pool, letterStats, recentHistoryRef.current) + recentHistoryRef.current = [...recentHistoryRef.current.slice(-4), c] + setChallenge(c) + setResult(null) + incorrectRecordedRef.current = false + audio.stop() + }, [progress?.level, letterStats]) + + // Load first challenge after progress loads + useEffect(() => { + if (!loading && progress && !challenge) { + nextChallenge() + } + }, [loading, progress]) + + async function handleAnswer(answer: string) { + if (!challenge || result === 'correct') return + const isCorrect = answer === challenge.toUpperCase() + setResult(isCorrect ? 'correct' : 'incorrect') + + if (isCorrect) { + const updated = await recordAnswer(challenge, true) + // Level-up trigger: 10 consecutive correct + if (updated.streak > 0 && updated.streak % 10 === 0) { + setShowLevelUp(true) + } + } else if (!incorrectRecordedRef.current) { + // Only record first incorrect attempt per challenge (allow re-tries without inflating stats) + incorrectRecordedRef.current = true + await recordAnswer(challenge, false) + } + } + + function playChallenge() { + if (!challenge) return + audio.play(challenge) + } + + if (loading || !progress || !challenge) { + return ( +
+

Loading quest...

+
+ ) + } + + return ( +
+ + + + + {result === 'correct' && ( +
+ +
+ )} + + {/* Level-up modal */} + {showLevelUp && ( +
+
+

+ Quest Complete! +

+

+ 10 in a row! {progress.level < 2 ? "You've advanced to Operator level!" : 'Outstanding skill!'} +

+ +
+
+ )} + + {/* Nav */} +
+ +
+
+ ) +}