|
| 1 | +import React, { useEffect, useMemo, useState } from "react"; |
| 2 | +import "../../styles/pages/games/TypingTest.css"; |
| 3 | + |
| 4 | +const SAMPLE_PARAGRAPHS = [ |
| 5 | + "The quick brown fox jumps over the lazy dog.", |
| 6 | + "Typing is a skill that improves with focus and consistency.", |
| 7 | + "React makes it painless to create interactive user interfaces.", |
| 8 | + "Code slowly, read carefully, and test your changes.", |
| 9 | + "Practice daily to increase your speed and accuracy." |
| 10 | +]; |
| 11 | + |
| 12 | +function getRandomParagraph() { |
| 13 | + const idx = Math.floor(Math.random() * SAMPLE_PARAGRAPHS.length); |
| 14 | + return SAMPLE_PARAGRAPHS[idx]; |
| 15 | +} |
| 16 | + |
| 17 | +export default function TypingTest() { |
| 18 | + const [targetText, setTargetText] = useState(getRandomParagraph); |
| 19 | + const [typedText, setTypedText] = useState(""); |
| 20 | + const [started, setStarted] = useState(false); |
| 21 | + const [time, setTime] = useState(0); |
| 22 | + const [finished, setFinished] = useState(false); |
| 23 | + const [bestWpm, setBestWpm] = useState(() => { |
| 24 | + const fromLS = localStorage.getItem("typing_best_wpm"); |
| 25 | + return fromLS ? Number(fromLS) : 0; |
| 26 | + }); |
| 27 | + |
| 28 | + // timer: start on first key, stop on finish |
| 29 | + useEffect(() => { |
| 30 | + let timer; |
| 31 | + if (started && !finished) { |
| 32 | + timer = setInterval(() => { |
| 33 | + setTime((t) => t + 1); |
| 34 | + }, 1000); |
| 35 | + } else { |
| 36 | + if (timer) clearInterval(timer); |
| 37 | + } |
| 38 | + return () => { |
| 39 | + if (timer) clearInterval(timer); |
| 40 | + }; |
| 41 | + }, [started, finished]); |
| 42 | + |
| 43 | + // derived metrics |
| 44 | + const stats = useMemo(() => { |
| 45 | + const wordsTyped = |
| 46 | + typedText.trim().length === 0 |
| 47 | + ? 0 |
| 48 | + : typedText.trim().split(/\s+/).length; |
| 49 | + const minutes = time / 60; |
| 50 | + const wpm = minutes > 0 ? Math.round(wordsTyped / minutes) : 0; |
| 51 | + |
| 52 | + // accuracy |
| 53 | + let correct = 0; |
| 54 | + for (let i = 0; i < typedText.length; i++) { |
| 55 | + if (typedText[i] === targetText[i]) correct++; |
| 56 | + } |
| 57 | + const accuracy = |
| 58 | + typedText.length === 0 |
| 59 | + ? 100 |
| 60 | + : Math.round((correct / typedText.length) * 100); |
| 61 | + |
| 62 | + return { wpm, accuracy, wordsTyped }; |
| 63 | + }, [typedText, time, targetText]); |
| 64 | + |
| 65 | + // when completed, stop and save best |
| 66 | + useEffect(() => { |
| 67 | + if (typedText === targetText && targetText.length > 0) { |
| 68 | + setFinished(true); |
| 69 | + setStarted(false); |
| 70 | + if (stats.wpm > bestWpm) { |
| 71 | + setBestWpm(stats.wpm); |
| 72 | + localStorage.setItem("typing_best_wpm", String(stats.wpm)); |
| 73 | + } |
| 74 | + } |
| 75 | + }, [typedText, targetText, stats.wpm, bestWpm]); |
| 76 | + |
| 77 | + function handleChange(e) { |
| 78 | + const val = e.target.value; |
| 79 | + |
| 80 | + // auto start on first key |
| 81 | + if (!started && !finished) { |
| 82 | + setStarted(true); |
| 83 | + } |
| 84 | + |
| 85 | + // restrict to paragraph length |
| 86 | + if (val.length <= targetText.length) { |
| 87 | + setTypedText(val); |
| 88 | + |
| 89 | + // auto end on completion |
| 90 | + if (val === targetText) { |
| 91 | + setFinished(true); |
| 92 | + setStarted(false); |
| 93 | + } |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + function handleRestart() { |
| 98 | + const nextPara = getRandomParagraph(); |
| 99 | + setTargetText(nextPara); |
| 100 | + setTypedText(""); |
| 101 | + setTime(0); |
| 102 | + setFinished(false); |
| 103 | + setStarted(false); |
| 104 | + } |
| 105 | + |
| 106 | + return ( |
| 107 | + <div className="typing-test-container"> |
| 108 | + <div className="typing-header"> |
| 109 | + <h2>Typing Speed Test</h2> |
| 110 | + <p>Type the text below as fast and as accurately as you can.</p> |
| 111 | + </div> |
| 112 | + |
| 113 | + <div className="typing-stats"> |
| 114 | + <div className="stat-card"> |
| 115 | + <span className="stat-label">Time</span> |
| 116 | + <span className="stat-value">{time}s</span> |
| 117 | + </div> |
| 118 | + <div className="stat-card"> |
| 119 | + <span className="stat-label">WPM</span> |
| 120 | + <span className="stat-value">{stats.wpm}</span> |
| 121 | + </div> |
| 122 | + <div className="stat-card"> |
| 123 | + <span className="stat-label">Accuracy</span> |
| 124 | + <span className="stat-value">{stats.accuracy}%</span> |
| 125 | + </div> |
| 126 | + <div className="stat-card"> |
| 127 | + <span className="stat-label">Best WPM</span> |
| 128 | + <span className="stat-value">{bestWpm}</span> |
| 129 | + </div> |
| 130 | + </div> |
| 131 | + |
| 132 | + <div className="typing-target"> |
| 133 | + {targetText.split("").map((ch, idx) => { |
| 134 | + let cls = ""; |
| 135 | + if (idx < typedText.length) { |
| 136 | + cls = typedText[idx] === ch ? "correct" : "incorrect"; |
| 137 | + } |
| 138 | + return ( |
| 139 | + <span key={idx} className={cls}> |
| 140 | + {ch} |
| 141 | + </span> |
| 142 | + ); |
| 143 | + })} |
| 144 | + </div> |
| 145 | + |
| 146 | + <textarea |
| 147 | + className="typing-input" |
| 148 | + value={typedText} |
| 149 | + onChange={handleChange} |
| 150 | + disabled={finished} |
| 151 | + placeholder="Start typing here..." |
| 152 | + /> |
| 153 | + |
| 154 | + <div className="typing-actions"> |
| 155 | + <button onClick={handleRestart} className="typing-btn"> |
| 156 | + Restart |
| 157 | + </button> |
| 158 | + {finished && ( |
| 159 | + <span className="typing-done"> |
| 160 | + ✅ Paragraph completed! Final WPM: {stats.wpm} |
| 161 | + </span> |
| 162 | + )} |
| 163 | + </div> |
| 164 | + </div> |
| 165 | + ); |
| 166 | +} |
0 commit comments