Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 103 additions & 26 deletions components/ChallengeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -31,45 +32,85 @@ export function ChallengeView({
const editorRef = useRef<VimEditorHandle>(null);
const resetButtonRef = useRef<HTMLButtonElement>(null);
const [tabPressed, setTabPressed] = useState(false);
const [sessionEnded, setSessionEnded] = useState(false);
const {
current,
status,
validate,
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);
Expand Down Expand Up @@ -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 (
<div className="flex flex-col items-center w-full max-w-3xl mx-auto">
<ResultsView
attempts={attempts}
duration={timerDuration}
onRestart={handleRestart}
/>
</div>
);
}

const remainingSeconds = Math.ceil(countdownRemaining);

return (
<div className="flex flex-col items-center gap-4 sm:gap-6 w-full max-w-3xl mx-auto">
{/* Stats row */}
<div className="flex items-center gap-6 sm:gap-8 font-mono text-sm">
<Tooltip text="Consecutive correct answers in a row">
<div className="flex items-center gap-2 text-mv-text-muted">
Expand Down Expand Up @@ -154,17 +206,44 @@ export function ChallengeView({
</span>
</div>
</Tooltip>
{timerEnabled && (
<Tooltip text="Average time to complete a challenge">
<div className="flex items-center gap-2 text-mv-text-muted">
<span className="text-mv-text-faint">time</span>
<span className={remainingSeconds <= 5 ? "text-mv-accent" : "text-mv-text-muted"}>
{remainingSeconds}s
<span className="text-mv-text-faint">avg time</span>
<span className={averageTimeMs > 0 ? "text-mv-accent" : "text-mv-text-muted"}>
{averageTimeMs > 0 ? `${(averageTimeMs / 1000).toFixed(1)}s` : "—"}
</span>
</div>
</Tooltip>
{timerEnabled && (
<Tooltip text="Time remaining in this session">
<div className="flex items-center gap-2 text-mv-text-muted">
<span className="text-mv-text-faint">time</span>
<motion.span
animate={
countdownRunning
? { scale: [1, 1.12, 1], opacity: [0.8, 1, 0.8] }
: { scale: 1, opacity: 1 }
}
transition={
countdownRunning
? { duration: 1, repeat: Infinity, ease: "easeInOut" }
: { duration: 0.2 }
}
className={`inline-block ${
countdownRunning && remainingSeconds <= 5
? "text-mv-accent font-semibold"
: countdownRunning
? "text-mv-accent"
: "text-mv-text-muted"
}`}
>
{remainingSeconds}s
</motion.span>
</div>
</Tooltip>
)}
</div>

{/* Challenge area */}
<AnimatePresence mode="wait">
<motion.div
key={challengeKey}
Expand All @@ -174,7 +253,6 @@ export function ChallengeView({
transition={{ duration: 0.15 }}
className="flex flex-col gap-4 w-full"
>
{/* Prompt */}
<div className="flex items-center justify-center gap-3 text-center">
<p className="text-mv-text font-mono text-base sm:text-lg">{current.prompt}</p>
<button
Expand Down Expand Up @@ -208,7 +286,6 @@ export function ChallengeView({
)}
</AnimatePresence>

{/* Editor */}
<div
className={`transition-all duration-200 rounded-lg ${statusBorderClass(status)}`}
>
Expand All @@ -217,15 +294,15 @@ export function ChallengeView({
initialContent={current.initialContent}
cursorPos={current.cursorPos}
onStateChange={validate}
onKeystroke={handleKeystrokeWithTimer}
onKeystroke={handleKeystroke}
onSkip={skip}
challengeKey={challengeKey}
waitingForStart={waitingForStart}
/>
</div>
</motion.div>
</AnimatePresence>

{/* Bottom row: restart centered, tips right */}
<div className="relative flex items-center justify-center w-full">
<button
ref={resetButtonRef}
Expand Down
144 changes: 144 additions & 0 deletions components/ResultsView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"use client";

import { motion } from "framer-motion";
import { RotateCcw } from "lucide-react";
import type { CommandAttempt } from "@/lib/types";

interface ResultsViewProps {
attempts: CommandAttempt[];
duration: number;
onRestart: () => void;
}

export function ResultsView({ attempts, duration, onRestart }: ResultsViewProps) {
const correct = attempts.filter((a) => a.correct);
const completed = correct.length;
const total = attempts.length;
const accuracy = total > 0 ? Math.round((completed / total) * 100) : 0;
const avgTimeMs =
completed > 0 ? correct.reduce((s, a) => s + a.timeMs, 0) / completed : 0;
const avgScore =
completed > 0
? Math.round(correct.reduce((s, a) => s + a.score, 0) / completed)
: 0;

const times = correct.map((a) => a.timeMs);
const fastestMs = times.length > 0 ? Math.min(...times) : 0;
const slowestMs = times.length > 0 ? Math.max(...times) : 0;
const chartMax = slowestMs > 0 ? slowestMs : 1;

return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className="flex flex-col gap-6 sm:gap-8 w-full"
>
<div className="flex flex-col items-center gap-1">
<p className="text-mv-text-faint font-mono text-[11px] uppercase tracking-[0.2em]">
time&apos;s up
</p>
<p className="font-mono text-mv-text text-2xl sm:text-3xl">
<span className="text-mv-accent">{completed}</span>{" "}
<span className="text-mv-text-muted text-base sm:text-lg">
challenge{completed === 1 ? "" : "s"} in {duration}s
</span>
</p>
</div>

<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
<Stat
label="accuracy"
value={total > 0 ? `${accuracy}%` : "—"}
accent={accuracy >= 80 && total > 0}
/>
<Stat
label="avg time"
value={completed > 0 ? `${(avgTimeMs / 1000).toFixed(1)}s` : "—"}
accent={completed > 0}
/>
<Stat
label="avg score"
value={completed > 0 ? `${avgScore}` : "—"}
accent={avgScore > 0}
/>
<Stat
label="fastest"
value={completed > 0 ? `${(fastestMs / 1000).toFixed(1)}s` : "—"}
accent={completed > 0}
/>
</div>

{completed > 0 && (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center justify-between">
<p className="text-mv-text-faint font-mono text-[10px] uppercase tracking-[0.2em]">
time per challenge
</p>
<p className="text-mv-text-faint font-mono text-[10px]">
{(fastestMs / 1000).toFixed(1)}s –{" "}
{(slowestMs / 1000).toFixed(1)}s
</p>
</div>
<div className="flex items-end gap-1 h-24 sm:h-32 w-full bg-mv-surface border border-mv-border rounded-lg p-3">
{times.map((t, i) => {
const heightPct = Math.max(4, (t / chartMax) * 100);
const isFastest = t === fastestMs && fastestMs !== slowestMs;
return (
<motion.div
key={i}
initial={{ height: 0, opacity: 0 }}
animate={{ height: `${heightPct}%`, opacity: 1 }}
transition={{
duration: 0.4,
delay: 0.1 + i * 0.04,
ease: "easeOut",
}}
className={`flex-1 rounded-t-sm ${
isFastest ? "bg-mv-accent" : "bg-mv-accent/50"
}`}
style={{ minWidth: "4px" }}
title={`#${i + 1}: ${(t / 1000).toFixed(2)}s`}
/>
);
})}
</div>
</div>
)}

<button
onClick={onRestart}
autoFocus
className="self-center flex items-center gap-2 px-5 py-2 rounded-lg bg-mv-accent text-mv-bg font-mono text-sm hover:opacity-90 transition-opacity cursor-pointer focus:outline-none focus:ring-2 focus:ring-mv-accent/40"
>
<RotateCcw size={14} />
restart
</button>
</motion.div>
);
}

function Stat({
label,
value,
accent,
}: {
label: string;
value: string;
accent: boolean;
}) {
return (
<div className="flex flex-col items-center gap-1 px-3 py-3 sm:py-4 bg-mv-surface border border-mv-border rounded-lg">
<span className="text-mv-text-faint font-mono text-[10px] uppercase tracking-[0.15em]">
{label}
</span>
<span
className={`font-mono text-lg sm:text-xl ${
accent ? "text-mv-accent" : "text-mv-text-muted"
}`}
>
{value}
</span>
</div>
);
}
Loading