From 29f994f0ccadb07ceda262533a0122cf936d0e35 Mon Sep 17 00:00:00 2001 From: RagibAhashan Date: Sat, 25 Apr 2026 23:47:10 -0400 Subject: [PATCH] Add session-based timed mode with results screen --- components/ChallengeView.tsx | 129 ++++++++++++++++++++++++------- components/ResultsView.tsx | 144 +++++++++++++++++++++++++++++++++++ components/VimEditor.tsx | 46 ++++++++--- hooks/useChallenge.ts | 66 ++++++++-------- 4 files changed, 314 insertions(+), 71 deletions(-) create mode 100644 components/ResultsView.tsx diff --git a/components/ChallengeView.tsx b/components/ChallengeView.tsx index 41d8566..109c167 100644 --- a/components/ChallengeView.tsx +++ b/components/ChallengeView.tsx @@ -6,6 +6,7 @@ import { RotateCcw, CircleHelp } from "lucide-react"; import { VimEditor, type VimEditorHandle } from "./VimEditor"; import { useChallenge, type ChallengeStatus } from "@/hooks/useChallenge"; import { useCountdown } from "@/hooks/useCountdown"; +import { ResultsView } from "./ResultsView"; import type { VimMode } from "@/lib/types"; import { Tooltip } from "./Tooltip"; @@ -31,6 +32,7 @@ export function ChallengeView({ const editorRef = useRef(null); const resetButtonRef = useRef(null); const [tabPressed, setTabPressed] = useState(false); + const [sessionEnded, setSessionEnded] = useState(false); const { current, status, @@ -38,38 +40,77 @@ export function ChallengeView({ handleKeystroke, streak, averageScore, + averageTimeMs, + attempts, skip, - timeout: onTimeout, reset, + resetSession, + startTimer, challengeKey, } = useChallenge(mode); - const countdown = useCountdown({ + const handleSessionExpire = useCallback(() => { + setSessionEnded(true); + }, []); + + const { + remaining: countdownRemaining, + running: countdownRunning, + start: startCountdown, + reset: resetCountdown, + } = useCountdown({ duration: timerDuration, - onExpire: onTimeout, + onExpire: handleSessionExpire, }); - // Start countdown on first keystroke when timer is enabled - const handleKeystrokeWithTimer = useCallback(() => { - handleKeystroke(); - if (timerEnabled && !countdown.running) { - countdown.start(); + const [prevTimerEnabled, setPrevTimerEnabled] = useState(timerEnabled); + const [waitingForStart, setWaitingForStart] = useState(timerEnabled); + if (prevTimerEnabled !== timerEnabled) { + setPrevTimerEnabled(timerEnabled); + setWaitingForStart(timerEnabled); + setSessionEnded(false); + } + + useEffect(() => { + if (!timerEnabled || sessionEnded) { + resetCountdown(); } - }, [handleKeystroke, timerEnabled, countdown]); + }, [timerEnabled, sessionEnded, resetCountdown]); - // Reset countdown when challenge advances or is answered correctly useEffect(() => { - countdown.reset(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [challengeKey, status]); + if (status === "active" && !waitingForStart && !sessionEnded) { + startTimer(); + } + }, [challengeKey, status, waitingForStart, sessionEnded, startTimer]); + + const handleRestart = useCallback(() => { + resetSession(); + setSessionEnded(false); + setWaitingForStart(timerEnabled); + resetCountdown(); + }, [resetSession, timerEnabled, resetCountdown]); - // Tab + Enter to reset challenge useEffect(() => { function handleKeyDown(e: KeyboardEvent) { const target = e.target as HTMLElement; const isInEditor = target?.closest?.(".cm-editor"); const isInInput = target?.tagName === "INPUT" || target?.tagName === "TEXTAREA"; + if (sessionEnded) { + return; + } + + if (waitingForStart && !isInInput) { + e.preventDefault(); + e.stopPropagation(); + if (e.key === " ") { + setWaitingForStart(false); + startCountdown(); + requestAnimationFrame(() => editorRef.current?.focus()); + } + return; + } + if (e.key === "Tab") { e.preventDefault(); setTabPressed(true); @@ -115,15 +156,26 @@ export function ChallengeView({ return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [tabPressed, reset, skip, onToggleHint]); + }, [tabPressed, reset, skip, onToggleHint, waitingForStart, startCountdown, sessionEnded]); if (!current) return null; - const remainingSeconds = Math.ceil(countdown.remaining); + if (sessionEnded) { + return ( +
+ +
+ ); + } + + const remainingSeconds = Math.ceil(countdownRemaining); return (
- {/* Stats row */}
@@ -154,17 +206,44 @@ export function ChallengeView({
- {timerEnabled && ( +
- time - - {remainingSeconds}s + avg time + 0 ? "text-mv-accent" : "text-mv-text-muted"}> + {averageTimeMs > 0 ? `${(averageTimeMs / 1000).toFixed(1)}s` : "—"}
+
+ {timerEnabled && ( + +
+ time + + {remainingSeconds}s + +
+
)}
- {/* Challenge area */} - {/* Prompt */}

{current.prompt}

+ + ); +} + +function Stat({ + label, + value, + accent, +}: { + label: string; + value: string; + accent: boolean; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} diff --git a/components/VimEditor.tsx b/components/VimEditor.tsx index 44b26ab..6eece0e 100644 --- a/components/VimEditor.tsx +++ b/components/VimEditor.tsx @@ -15,6 +15,7 @@ interface VimEditorProps { onKeystroke?: () => void; onSkip?: () => void; challengeKey: string; + waitingForStart?: boolean; } export interface VimEditorHandle { @@ -22,7 +23,7 @@ export interface VimEditorHandle { } export const VimEditor = forwardRef( - function VimEditor({ initialContent, cursorPos, onStateChange, onKeystroke, onSkip, challengeKey }, ref) { + function VimEditor({ initialContent, cursorPos, onStateChange, onKeystroke, onSkip, challengeKey, waitingForStart = false }, ref) { const containerRef = useRef(null); const viewRef = useRef(null); const themeCompartment = useRef(new Compartment()); @@ -37,6 +38,8 @@ export const VimEditor = forwardRef( onKeystrokeRef.current = onKeystroke; const onSkipRef = useRef(onSkip); onSkipRef.current = onSkip; + const waitingForStartRef = useRef(waitingForStart); + waitingForStartRef.current = waitingForStart; useImperativeHandle(ref, () => ({ focus: () => { @@ -153,7 +156,9 @@ export const VimEditor = forwardRef( view.contentDOM.addEventListener("keydown", handleKeyDown); requestAnimationFrame(() => { - view.focus(); + if (!waitingForStartRef.current) { + view.focus(); + } }); return () => { @@ -173,6 +178,16 @@ export const VimEditor = forwardRef( }); }, [theme]); + useEffect(() => { + const view = viewRef.current; + if (!view) return; + if (waitingForStart) { + view.contentDOM.blur(); + } else { + view.focus(); + } + }, [waitingForStart]); + const handleOverlayClick = () => { viewRef.current?.focus(); }; @@ -180,16 +195,27 @@ export const VimEditor = forwardRef( return (
- {/* Unfocused overlay */} - {!focused && ( -
-

- Click here or press any key to focus + {waitingForStart ? ( +

+

+ Press{" "} + + space + {" "} + to start

+ ) : ( + !focused && ( +
+

+ Click here or press any key to focus +

+
+ ) )}
); diff --git a/hooks/useChallenge.ts b/hooks/useChallenge.ts index c513504..1c7cfa5 100644 --- a/hooks/useChallenge.ts +++ b/hooks/useChallenge.ts @@ -8,13 +8,13 @@ import { useTimer } from "./useTimer"; export type ChallengeStatus = "active" | "correct"; export function useChallenge(mode: VimMode) { - const [challenges] = useState(() => shuffleChallenges(getChallengesForMode(mode))); + const [challenges, setChallenges] = useState(() => shuffleChallenges(getChallengesForMode(mode))); const [currentIndex, setCurrentIndex] = useState(0); const [resetCount, setResetCount] = useState(0); const [status, setStatus] = useState("active"); const [attempts, setAttempts] = useState([]); const [streak, setStreak] = useState(0); - const timer = useTimer(); + const { elapsed, start: timerStart, stop: timerStop, reset: timerReset } = useTimer(); const hasStartedRef = useRef(false); const keystrokeCountRef = useRef(0); @@ -27,25 +27,22 @@ export function useChallenge(mode: VimMode) { const startTimer = useCallback(() => { if (!hasStartedRef.current) { hasStartedRef.current = true; - timer.start(); + timerStart(); } - }, [timer]); + }, [timerStart]); const advance = useCallback(() => { setCurrentIndex((i) => i + 1); setStatus("active"); hasStartedRef.current = false; keystrokeCountRef.current = 0; - timer.reset(); - }, [timer]); + timerReset(); + }, [timerReset]); const validate = useCallback( (content: string, cursorPos: number) => { if (!current || status !== "active") return; - // Start timer on first interaction - startTimer(); - const contentMatch = content === current.expectedContent; const isMotionOnly = current.initialContent === current.expectedContent; const cursorMatch = cursorPos === current.expectedCursorPos; @@ -53,7 +50,7 @@ export function useChallenge(mode: VimMode) { const correct = isMotionOnly ? cursorMatch : contentMatch; if (correct) { - const timeMs = timer.stop(); + const timeMs = timerStop(); const keystrokes = keystrokeCountRef.current; const ideal = current.expectedCommand.length; const score = keystrokes <= ideal ? 100 : Math.max(0, Math.round(100 * ideal / keystrokes)); @@ -75,7 +72,7 @@ export function useChallenge(mode: VimMode) { } // No incorrect auto-validation — user has unlimited time }, - [current, status, timer, startTimer, advance] + [current, status, timerStop, advance] ); const skip = useCallback(() => { @@ -85,7 +82,7 @@ export function useChallenge(mode: VimMode) { { challengeId: current.id, correct: false, - timeMs: timer.stop(), + timeMs: timerStop(), timestamp: Date.now(), keystrokeCount: keystrokeCountRef.current, score: 0, @@ -94,38 +91,35 @@ export function useChallenge(mode: VimMode) { } setStreak(0); advance(); - }, [current, timer, advance]); - - const timeout = useCallback(() => { - if (current) { - setAttempts((prev) => [ - ...prev, - { - challengeId: current.id, - correct: false, - timeMs: timer.stop(), - timestamp: Date.now(), - keystrokeCount: keystrokeCountRef.current, - score: 0, - }, - ]); - } - setStreak(0); - advance(); - }, [current, timer, advance]); + }, [current, timerStop, advance]); const reset = useCallback(() => { setResetCount((c) => c + 1); setStatus("active"); hasStartedRef.current = false; keystrokeCountRef.current = 0; - timer.reset(); - }, [timer]); + timerReset(); + }, [timerReset]); + + const resetSession = useCallback(() => { + setChallenges(shuffleChallenges(getChallengesForMode(mode))); + setCurrentIndex(0); + setResetCount((c) => c + 1); + setStatus("active"); + setAttempts([]); + setStreak(0); + hasStartedRef.current = false; + keystrokeCountRef.current = 0; + timerReset(); + }, [mode, timerReset]); const correctAttemptsList = attempts.filter((a) => a.correct); const averageScore = correctAttemptsList.length > 0 ? Math.round(correctAttemptsList.reduce((sum, a) => sum + a.score, 0) / correctAttemptsList.length) : 0; + const averageTimeMs = correctAttemptsList.length > 0 + ? correctAttemptsList.reduce((sum, a) => sum + a.timeMs, 0) / correctAttemptsList.length + : 0; return { current, @@ -134,11 +128,13 @@ export function useChallenge(mode: VimMode) { handleKeystroke, streak, averageScore, - elapsed: timer.elapsed, + averageTimeMs, + elapsed, + startTimer, attempts, skip, - timeout, reset, + resetSession, challengeKey: `${mode}-${currentIndex}-${resetCount}`, }; }