diff --git a/App.tsx b/App.tsx index 519a5ea..8c20fc8 100644 --- a/App.tsx +++ b/App.tsx @@ -1,11 +1,10 @@ - import React, { useState, useEffect, useCallback, useRef } from 'react'; import TypingArea from './components/TypingArea'; import StatsOverlay from './components/StatsOverlay'; -import { TestStatus, TypingStats, TestConfig, Quote } from './types'; +import { TestStatus, InputMode, TypingStats, TestConfig, Quote } from './types'; import { DEFAULT_QUOTES, DURATIONS } from './constants'; -// Add type definition for Web Speech API +// Web Speech API type declarations declare global { interface Window { SpeechRecognition: any; @@ -13,52 +12,48 @@ declare global { } } +const EMPTY_STATS: TypingStats = { + wpm: 0, + accuracy: 100, + charactersTyped: 0, + totalKeystrokes: 0, + incorrectKeystrokes: 0, + timeTaken: 0, + wordsTyped: 0, +}; + const App: React.FC = () => { - // State const [isDark, setIsDark] = useState(true); const [status, setStatus] = useState(TestStatus.IDLE); + const [inputMode, setInputMode] = useState(InputMode.VOICE); const [config, setConfig] = useState({ duration: 30 }); const [quote, setQuote] = useState(DEFAULT_QUOTES[0]); const [userInput, setUserInput] = useState(''); const [timeLeft, setTimeLeft] = useState(config.duration); const [isListening, setIsListening] = useState(false); + const [sessionStats, setSessionStats] = useState({ ...EMPTY_STATS }); - // Cumulative stats for the whole test session - const [sessionStats, setSessionStats] = useState({ - wpm: 0, - accuracy: 100, - charactersTyped: 0, - totalKeystrokes: 0, - incorrectKeystrokes: 0, - timeTaken: 0 - }); - - // Refs const timerRef = useRef(null); const startTimeRef = useRef(null); - const statsRef = useRef(sessionStats); const recognitionRef = useRef(null); - // Ref to hold latest state/handlers to avoid stale closures in event listeners + // Synchronously update this ref during render so speech callbacks + // always see the freshest state without stale closures. const latestRef = useRef({ status, isListening, quote, - sessionStats, - handleSpeechInput: (val: string) => { } // Placeholder + handleSpeechInput: (_val: string) => {}, }); - - // Keep ref in sync for interval access - useEffect(() => { - statsRef.current = sessionStats; - }, [sessionStats]); + latestRef.current.status = status; + latestRef.current.isListening = isListening; + latestRef.current.quote = quote; const getRandomQuote = (currentQuoteText?: string): Quote => { - let filtered = DEFAULT_QUOTES; - if (currentQuoteText) { - filtered = DEFAULT_QUOTES.filter(q => q.text !== currentQuoteText); - } - return filtered[Math.floor(Math.random() * filtered.length)]; + const pool = currentQuoteText + ? DEFAULT_QUOTES.filter((q) => q.text !== currentQuoteText) + : DEFAULT_QUOTES; + return pool[Math.floor(Math.random() * pool.length)]; }; const finishTest = useCallback(() => { @@ -73,162 +68,209 @@ const App: React.FC = () => { setStatus(TestStatus.FINISHED); }, []); - const resetTest = useCallback((newDuration?: number) => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - if (recognitionRef.current) { - recognitionRef.current.stop(); - } - - const d = newDuration ?? config.duration; - setStatus(TestStatus.IDLE); - setUserInput(''); - setIsListening(false); - setTimeLeft(d); - setQuote(getRandomQuote()); - setSessionStats({ - wpm: 0, - accuracy: 100, - charactersTyped: 0, - totalKeystrokes: 0, - incorrectKeystrokes: 0, - timeTaken: 0 - }); - startTimeRef.current = null; - }, [config.duration]); - - // Handle Speech Input Definition - const handleSpeechInput = (value: string) => { - if (status === TestStatus.FINISHED) return; - - // Normalization helper (lowercase, remove punctuation) - const normalize = (str: string) => str.toLowerCase().replace(/[^\w\s]/g, ''); - - // Split target into words, keeping their original punctuation for reconstruction if needed - // But simply, we want to match against the *words* of the target. - const targetWords = quote.text.split(' '); - const spokenWords = value.trim().split(' '); - - let matchedWordCount = 0; + const resetTest = useCallback( + (newDuration?: number) => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + if (recognitionRef.current) { + try { + recognitionRef.current.stop(); + } catch (_) {} + } + const d = newDuration ?? config.duration; + setStatus(TestStatus.IDLE); + setUserInput(''); + setIsListening(false); + setTimeLeft(d); + setQuote(getRandomQuote()); + setSessionStats({ ...EMPTY_STATS }); + startTimeRef.current = null; + }, + [config.duration] + ); - // Check how many words match from the beginning - // We compare normalized spoken words against normalized target words - for (let i = 0; i < spokenWords.length && i < targetWords.length; i++) { - const spoken = normalize(spokenWords[i]); - const target = normalize(targetWords[i]); + // ─── Voice input handler ────────────────────────────────────────────────── + + /** + * Called on every speech recognition result (including interim). + * Builds a prefix-matched input string from the transcript. + * + * BUG FIXED: original code used .join('') which concatenated speech + * segments without spaces. Now we trim each segment and join with ' '. + */ + const handleSpeechInput = useCallback( + (value: string) => { + // Read the latest quote from the ref to avoid stale closures + const currentQuote = latestRef.current.quote; + if (latestRef.current.status === TestStatus.FINISHED) return; + + const normalize = (str: string) => + str.toLowerCase().replace(/[^\w\s]/g, '').trim(); + + const targetWords = currentQuote.text.split(' '); + const spokenWords = value.trim().split(/\s+/).filter(Boolean); + + let matchedWordCount = 0; + for ( + let i = 0; + i < spokenWords.length && i < targetWords.length; + i++ + ) { + if (normalize(spokenWords[i]) === normalize(targetWords[i])) { + matchedWordCount++; + } else { + break; + } + } - if (spoken === target) { - matchedWordCount++; - } else { - break; + if (matchedWordCount > 0) { + const constructedInput = targetWords + .slice(0, matchedWordCount) + .join(' '); + // Append trailing space if more words remain (visual cue) + const displayInput = + matchedWordCount < targetWords.length + ? constructedInput + ' ' + : constructedInput; + + setUserInput(displayInput); + + // Completed entire quote + if (matchedWordCount === targetWords.length) { + setSessionStats((prev) => ({ + ...prev, + charactersTyped: prev.charactersTyped + constructedInput.length, + wordsTyped: prev.wordsTyped + matchedWordCount, + })); + + setUserInput(''); + // Stop recognition so the buffer clears; onend will restart it + if (recognitionRef.current) { + try { + recognitionRef.current.stop(); + } catch (_) {} + } + setQuote(getRandomQuote(currentQuote.text)); + } } - } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] // intentionally empty — we read all state from latestRef + ); - // Reconstruction: - let constructedInput = ''; - if (matchedWordCount > 0) { - constructedInput = targetWords.slice(0, matchedWordCount).join(' '); - if (matchedWordCount < targetWords.length) { - constructedInput += ' '; + // Keep the latest handler in latestRef (synchronous, so recognition + // callbacks see the current version without waiting for an effect). + latestRef.current.handleSpeechInput = handleSpeechInput; + + // ─── Keyboard input handler ─────────────────────────────────────────────── + + const handleKeyInput = useCallback( + (value: string) => { + if (status !== TestStatus.RUNNING) return; + setUserInput(value); + + // Check for quote completion (exact match) + if (value === quote.text) { + setSessionStats((prev) => ({ + ...prev, + charactersTyped: prev.charactersTyped + value.length, + wordsTyped: prev.wordsTyped + quote.text.split(' ').length, + })); + setUserInput(''); + setQuote(getRandomQuote(quote.text)); } - } + }, + [status, quote] + ); + + /** + * Per-keystroke accuracy tracking for keyboard mode. + * Called from TypingArea's onKeyDown before the input value changes. + */ + const handleKeystroke = useCallback((isCorrect: boolean) => { + setSessionStats((prev) => { + const totalKeystrokes = prev.totalKeystrokes + 1; + const incorrectKeystrokes = + prev.incorrectKeystrokes + (isCorrect ? 0 : 1); + const accuracy = + ((totalKeystrokes - incorrectKeystrokes) / totalKeystrokes) * 100; + return { ...prev, totalKeystrokes, incorrectKeystrokes, accuracy }; + }); + }, []); - const accuracy = 100; + // ─── Start / speech recognition ────────────────────────────────────────── - if (matchedWordCount > 0) { - setSessionStats(prev => ({ - ...prev, - wpm: prev.wpm, - accuracy: accuracy, - totalKeystrokes: constructedInput.length, - incorrectKeystrokes: 0 - })); + const startSpeechRecognition = useCallback(() => { + const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition; - setUserInput(constructedInput); + if (!SpeechRecognition) { + alert( + 'Web Speech API is not supported in this browser. Try Chrome or Edge, or switch to Keyboard mode.' + ); + return; } - // Check if we matched all words - if (matchedWordCount === targetWords.length) { - setSessionStats(prev => ({ - ...prev, - charactersTyped: prev.charactersTyped + constructedInput.length - })); + const recognition = new SpeechRecognition(); + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = 'en-US'; + + recognition.onresult = (event: any) => { + /** + * BUG FIXED: original code used .join('') — concatenating all speech + * segments without spaces, turning "hello world" into "helloworld". + * + * Fix: trim each segment's transcript and join with a single space. + * This handles both single-segment and multi-segment results correctly. + */ + const transcript = Array.from(event.results as any[]) + .map((result: any) => result[0].transcript.trim()) + .filter(Boolean) + .join(' '); + + latestRef.current.handleSpeechInput(transcript); + }; - setUserInput(''); + recognition.onerror = (event: any) => { + console.error('Speech recognition error:', event.error); + if (event.error === 'not-allowed') { + alert( + 'Microphone access denied. Please allow microphone permissions and try again.' + ); + finishTest(); + } + }; - // Stop recognition to clear buffer - if (recognitionRef.current) { - recognitionRef.current.stop(); + recognition.onend = () => { + // Auto-restart on silence timeouts while the test is still running + const { status: s, isListening: listening } = latestRef.current; + if (s === TestStatus.RUNNING && listening) { + try { + recognition.start(); + } catch (_) { + // Already started or recognition ended cleanly — safe to ignore + } } + }; - setQuote(getRandomQuote(quote.text)); + try { + recognition.start(); + recognitionRef.current = recognition; + } catch (e) { + console.error('Failed to start recognition:', e); } - }; + }, [finishTest]); - // Update latestRef on every render so callbacks see fresh data/handlers - useEffect(() => { - latestRef.current = { - status, - isListening, - quote, - sessionStats, - handleSpeechInput - }; - }, [status, isListening, quote, sessionStats, handleSpeechInput]); // handleSpeechInput is constant if defined outside or depends on these - - const startTest = () => { + const startTest = useCallback(() => { setStatus(TestStatus.RUNNING); startTimeRef.current = Date.now(); - setIsListening(true); - - // Start Speech Recognition - const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; - if (SpeechRecognition) { - const recognition = new SpeechRecognition(); - recognition.continuous = true; - recognition.interimResults = true; - recognition.lang = 'en-US'; - - recognition.onresult = (event: any) => { - const currentTranscript = Array.from(event.results) - .map((result: any) => result[0].transcript) - .join(''); - - // Use latestRef to call the fresh handler - latestRef.current.handleSpeechInput(currentTranscript); - }; - - recognition.onerror = (event: any) => { - console.error('Speech recognition error', event.error); - if (event.error === 'not-allowed') { - alert('Microphone access denied. Please enable microphone permissions.'); - } - }; - - recognition.onend = () => { - // Use latestRef to check fresh state - const { status, isListening } = latestRef.current; - - // Restart if still running (handles silence timeouts) - if (status === TestStatus.RUNNING && isListening) { - try { - recognition.start(); - } catch (e) { - // ignore - } - } - }; - try { - recognition.start(); - recognitionRef.current = recognition; - } catch (e) { - console.error('Failed to start recognition', e); - } - } else { - alert('Web Speech API not supported in this browser.'); + if (inputMode === InputMode.VOICE) { + setIsListening(true); + startSpeechRecognition(); } timerRef.current = window.setInterval(() => { @@ -240,53 +282,116 @@ const App: React.FC = () => { return prev - 1; }); }, 1000); - }; + }, [inputMode, startSpeechRecognition, finishTest]); - // Clean up on unmount - useEffect(() => { - return () => { - if (recognitionRef.current) recognitionRef.current.stop(); - if (timerRef.current) clearInterval(timerRef.current); - } - }, []); + // ─── Real-time WPM update ───────────────────────────────────────────────── - // Real-time WPM calculation useEffect(() => { if (status !== TestStatus.RUNNING || !startTimeRef.current) return; const updateWpm = () => { const timeElapsedSec = (Date.now() - startTimeRef.current!) / 1000; const timeElapsedMin = timeElapsedSec / 60; + if (timeElapsedMin <= 0) return; + + setSessionStats((prev) => { + // WPM = (completed chars + chars of current in-progress input) / 5 / minutes + const totalChars = prev.charactersTyped + userInput.trimEnd().length; + const currentWpm = totalChars / 5 / timeElapsedMin; + return { + ...prev, + wpm: currentWpm, + timeTaken: timeElapsedMin, + }; + }); + }; + + const interval = setInterval(updateWpm, 200); + return () => clearInterval(interval); + }, [status, userInput]); - // WPM = (all previously finished quotes chars + current input chars) / 5 / minutes - const totalChars = sessionStats.charactersTyped + userInput.length; - const currentWpm = timeElapsedMin > 0 ? (totalChars / 5) / timeElapsedMin : 0; + // ─── Cleanup on unmount ─────────────────────────────────────────────────── - setSessionStats(prev => ({ - ...prev, - wpm: currentWpm, - timeTaken: timeElapsedMin - })); + useEffect(() => { + return () => { + if (recognitionRef.current) recognitionRef.current.stop(); + if (timerRef.current) clearInterval(timerRef.current); }; + }, []); - const interval = setInterval(updateWpm, 100); - return () => clearInterval(interval); - }, [status, userInput.length, sessionStats.charactersTyped]); + // ─── Derived display values ─────────────────────────────────────────────── + + const themeClass = isDark + ? 'bg-[#161617] text-[#f5f5f7]' + : 'bg-[#fbfbfd] text-[#1d1d1f]'; + const navClass = isDark + ? 'bg-black/70 border-white/10' + : 'bg-white/70 border-gray-200/50'; + + const isVoice = inputMode === InputMode.VOICE; - const themeClass = isDark ? 'bg-[#161617] text-[#f5f5f7]' : 'bg-[#fbfbfd] text-[#1d1d1f]'; - const navClass = isDark ? 'bg-black/70 border-white/10' : 'bg-white/70 border-gray-200/50'; + // Compute displayed accuracy: for voice mode it's always 100 (only exact + // matches advance the cursor); for keyboard mode it's tracked per keystroke. + const displayAccuracy = isVoice ? 100 : Math.round(sessionStats.accuracy); return ( -
-