This commit is contained in:
polwex 2026-03-24 16:12:30 +07:00
commit d42e47b15b
31 changed files with 3045 additions and 0 deletions

View file

@ -0,0 +1,122 @@
import { useState, useCallback, useRef, useEffect } from 'react'
export type TypingEngineResult = {
currentIndex: number
errors: Set<number>
wpm: number
accuracy: number
isComplete: boolean
keyStats: Record<string, { hits: number; misses: number }>
handleKeyDown: (e: React.KeyboardEvent) => void
reset: (newText?: string) => void
}
export function useTypingEngine(
initialText: string,
onComplete?: (result: { wpm: number; accuracy: number; keyStats: Record<string, { hits: number; misses: number }> }) => void,
): TypingEngineResult {
const [text, setText] = useState(initialText)
const [currentIndex, setCurrentIndex] = useState(0)
const [errors, setErrors] = useState<Set<number>>(new Set())
const [totalKeystrokes, setTotalKeystrokes] = useState(0)
const [correctKeystrokes, setCorrectKeystrokes] = useState(0)
const [isComplete, setIsComplete] = useState(false)
const [keyStats, setKeyStats] = useState<Record<string, { hits: number; misses: number }>>({})
const startTimeRef = useRef<number | null>(null)
const [wpm, setWpm] = useState(0)
const onCompleteRef = useRef(onComplete)
onCompleteRef.current = onComplete
const accuracy = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100
// Update WPM periodically
useEffect(() => {
if (isComplete || currentIndex === 0) return
const interval = setInterval(() => {
if (startTimeRef.current) {
const minutes = (Date.now() - startTimeRef.current) / 60000
if (minutes > 0) {
setWpm(Math.round((currentIndex / 5) / minutes))
}
}
}, 500)
return () => clearInterval(interval)
}, [currentIndex, isComplete])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isComplete) return
// Ignore modifier keys, function keys, etc.
if (e.key.length > 1 && e.key !== 'Backspace') return
e.preventDefault()
if (e.key === 'Backspace') {
setCurrentIndex(prev => Math.max(0, prev - 1))
setErrors(prev => {
const next = new Set(prev)
next.delete(currentIndex - 1)
return next
})
return
}
if (!startTimeRef.current) {
startTimeRef.current = Date.now()
}
const expected = text[currentIndex]
const typed = e.key
const charKey = expected?.toLowerCase() ?? typed.toLowerCase()
setTotalKeystrokes(prev => prev + 1)
if (typed === expected) {
setCorrectKeystrokes(prev => prev + 1)
setKeyStats(prev => ({
...prev,
[charKey]: { hits: (prev[charKey]?.hits ?? 0) + 1, misses: prev[charKey]?.misses ?? 0 },
}))
const nextIndex = currentIndex + 1
setCurrentIndex(nextIndex)
if (nextIndex >= text.length) {
setIsComplete(true)
const minutes = (Date.now() - (startTimeRef.current ?? Date.now())) / 60000
const finalWpm = minutes > 0 ? Math.round((text.length / 5) / minutes) : 0
setWpm(finalWpm)
const finalAccuracy = (totalKeystrokes + 1) > 0
? Math.round(((correctKeystrokes + 1) / (totalKeystrokes + 1)) * 100)
: 100
// Build final keyStats including this last keystroke
setKeyStats(prev => {
const final = {
...prev,
[charKey]: { hits: (prev[charKey]?.hits ?? 0) + 1, misses: prev[charKey]?.misses ?? 0 },
}
onCompleteRef.current?.({ wpm: finalWpm, accuracy: finalAccuracy, keyStats: final })
return prev // Don't double-count, already set above
})
}
} else {
setErrors(prev => new Set(prev).add(currentIndex))
setKeyStats(prev => ({
...prev,
[charKey]: { hits: prev[charKey]?.hits ?? 0, misses: (prev[charKey]?.misses ?? 0) + 1 },
}))
}
}, [currentIndex, text, isComplete, totalKeystrokes, correctKeystrokes])
const reset = useCallback((newText?: string) => {
if (newText !== undefined) setText(newText)
setCurrentIndex(0)
setErrors(new Set())
setTotalKeystrokes(0)
setCorrectKeystrokes(0)
setIsComplete(false)
setKeyStats({})
setWpm(0)
startTimeRef.current = null
}, [])
return { currentIndex, errors, wpm, accuracy, isComplete, keyStats, handleKeyDown, reset }
}