360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useRef } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import type { MathProblem, Difficulty } from "@/lib/problems/types";
|
|
import {
|
|
checkFractionAnswer,
|
|
checkDecimalAnswer,
|
|
checkRatioAnswer,
|
|
checkIntegerAnswer,
|
|
parseStrictDecimal,
|
|
parseStrictSignedDecimal,
|
|
parseStrictSignedInt,
|
|
type AnswerResult,
|
|
} from "@/lib/math/validation";
|
|
import { FractionInput, DecimalInput, RatioInput } from "./fraction-input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import { MathDisplay } from "@/components/math/math-display";
|
|
import katex from "katex";
|
|
|
|
interface PracticeSectionProps {
|
|
title: string;
|
|
generator: (difficulty: Difficulty) => MathProblem;
|
|
unitColor?: "unit-1" | "unit-2" | "unit-3" | "unit-4";
|
|
}
|
|
|
|
type UnitColor = NonNullable<PracticeSectionProps["unitColor"]>;
|
|
|
|
const activeDifficultyStyle: Record<UnitColor, string> = {
|
|
"unit-1": "bg-unit-1 text-white",
|
|
"unit-2": "bg-unit-2 text-white",
|
|
"unit-3": "bg-unit-3 text-white",
|
|
"unit-4": "bg-unit-4 text-white",
|
|
};
|
|
|
|
const RECENT_PROBLEM_LIMIT = 8;
|
|
const MAX_GENERATION_RETRIES = 12;
|
|
|
|
function getProblemKey(problem: MathProblem): string {
|
|
return JSON.stringify({
|
|
prompt: problem.prompt,
|
|
answer: problem.answer,
|
|
});
|
|
}
|
|
|
|
export function PracticeSection({ title, generator, unitColor = "unit-1" }: PracticeSectionProps) {
|
|
const recentProblemKeysRef = useRef<string[] | null>(null);
|
|
const [difficulty, setDifficulty] = useState<Difficulty>(1);
|
|
const [problem, setProblem] = useState<MathProblem>(() => generator(1));
|
|
const [userAnswer, setUserAnswer] = useState<Record<string, string>>({});
|
|
const [result, setResult] = useState<AnswerResult | null>(null);
|
|
const [showHint, setShowHint] = useState(false);
|
|
const [hintIndex, setHintIndex] = useState(0);
|
|
const [showSolution, setShowSolution] = useState(false);
|
|
const [score, setScore] = useState({ correct: 0, total: 0 });
|
|
const isFullyCorrect = result?.correct === true && result.simplified === true;
|
|
|
|
const generateUniqueProblem = useCallback((diff: Difficulty): MathProblem => {
|
|
const recentProblemKeys = recentProblemKeysRef.current ?? [getProblemKey(problem)];
|
|
let nextProblem = generator(diff);
|
|
let nextKey = getProblemKey(nextProblem);
|
|
let retries = 0;
|
|
|
|
while (
|
|
recentProblemKeys.includes(nextKey) &&
|
|
retries < MAX_GENERATION_RETRIES
|
|
) {
|
|
nextProblem = generator(diff);
|
|
nextKey = getProblemKey(nextProblem);
|
|
retries += 1;
|
|
}
|
|
|
|
recentProblemKeysRef.current = [
|
|
nextKey,
|
|
...recentProblemKeys.filter((key) => key !== nextKey),
|
|
].slice(0, RECENT_PROBLEM_LIMIT);
|
|
|
|
return nextProblem;
|
|
}, [generator, problem]);
|
|
|
|
const generateNew = useCallback((diff: Difficulty) => {
|
|
setProblem(generateUniqueProblem(diff));
|
|
setUserAnswer({});
|
|
setResult(null);
|
|
setShowHint(false);
|
|
setHintIndex(0);
|
|
setShowSolution(false);
|
|
}, [generateUniqueProblem]);
|
|
|
|
function checkAnswer() {
|
|
const answer = problem.answer;
|
|
let res: AnswerResult;
|
|
|
|
switch (answer.kind) {
|
|
case "fraction": {
|
|
const num = parseStrictSignedInt(userAnswer.numerator || "");
|
|
const den = parseStrictSignedInt(userAnswer.denominator || "");
|
|
if (num === null || den === null) {
|
|
res = { correct: false, message: "Enter valid numbers" };
|
|
} else {
|
|
res = checkFractionAnswer(num, den, answer.numerator, answer.denominator);
|
|
}
|
|
break;
|
|
}
|
|
case "decimal": {
|
|
const val = parseStrictSignedDecimal(userAnswer.value || "");
|
|
if (val === null) {
|
|
res = { correct: false, message: "Enter a valid number" };
|
|
} else {
|
|
res = checkDecimalAnswer(val, answer.value);
|
|
}
|
|
break;
|
|
}
|
|
case "integer": {
|
|
const val = parseStrictSignedInt(userAnswer.value || "");
|
|
if (val === null) {
|
|
res = { correct: false, message: "Enter a valid number" };
|
|
} else {
|
|
res = checkIntegerAnswer(val, answer.value);
|
|
}
|
|
break;
|
|
}
|
|
case "ratio": {
|
|
const parts = (userAnswer.ratio || "").split(":").map((p) => parseInt(p.trim()));
|
|
if (parts.some(isNaN)) {
|
|
res = { correct: false, message: "Enter valid ratio parts" };
|
|
} else {
|
|
res = checkRatioAnswer(parts, answer.parts);
|
|
}
|
|
break;
|
|
}
|
|
case "standardForm": {
|
|
const coeff = parseStrictDecimal(userAnswer.coefficient || "");
|
|
const exp = parseStrictSignedInt(userAnswer.exponent || "");
|
|
if (coeff === null || exp === null) {
|
|
res = { correct: false, message: "Enter valid numbers" };
|
|
} else if (
|
|
Math.abs(coeff - answer.coefficient) < 0.01 &&
|
|
exp === answer.exponent
|
|
) {
|
|
res = { correct: true, simplified: true };
|
|
} else {
|
|
res = { correct: false, message: "That's not quite right. Try again!" };
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
setResult(res);
|
|
if (res.correct) {
|
|
setScore((s) => ({ correct: s.correct + 1, total: s.total + 1 }));
|
|
} else {
|
|
setScore((s) => ({ ...s, total: s.total + 1 }));
|
|
}
|
|
}
|
|
|
|
function nextHint() {
|
|
if (!showHint) {
|
|
setShowHint(true);
|
|
return;
|
|
}
|
|
if (hintIndex < problem.hints.length - 1) {
|
|
setHintIndex((i) => i + 1);
|
|
}
|
|
}
|
|
|
|
const promptHtml = katex.renderToString(problem.prompt, { throwOnError: false, displayMode: true });
|
|
|
|
return (
|
|
<Card className="mt-8 border-2">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h3 className="text-lg font-extrabold">{title}</h3>
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="rounded-full bg-background px-2.5 py-1 font-bold text-muted">
|
|
{score.correct}/{score.total}
|
|
</span>
|
|
<div className="flex gap-1">
|
|
{([1, 2, 3] as Difficulty[]).map((d) => (
|
|
<button
|
|
key={d}
|
|
onClick={() => {
|
|
setDifficulty(d);
|
|
generateNew(d);
|
|
}}
|
|
className={`rounded-lg px-2.5 py-1 text-xs font-extrabold transition-colors ${
|
|
difficulty === d
|
|
? activeDifficultyStyle[unitColor]
|
|
: "bg-border/70 text-muted hover:bg-border hover:text-foreground"
|
|
}`}
|
|
>
|
|
L{d}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Problem */}
|
|
<div
|
|
className="mb-6 text-center text-xl"
|
|
dangerouslySetInnerHTML={{ __html: promptHtml }}
|
|
/>
|
|
|
|
{/* Input */}
|
|
<div className="mb-4 flex flex-wrap items-center justify-center gap-4">
|
|
{problem.answer.kind === "fraction" && (
|
|
<FractionInput
|
|
numerator={userAnswer.numerator || ""}
|
|
denominator={userAnswer.denominator || ""}
|
|
onNumeratorChange={(v) => setUserAnswer((a) => ({ ...a, numerator: v }))}
|
|
onDenominatorChange={(v) => setUserAnswer((a) => ({ ...a, denominator: v }))}
|
|
disabled={isFullyCorrect}
|
|
/>
|
|
)}
|
|
|
|
{(problem.answer.kind === "decimal" || problem.answer.kind === "integer") && (
|
|
<DecimalInput
|
|
value={userAnswer.value || ""}
|
|
onChange={(v) => setUserAnswer((a) => ({ ...a, value: v }))}
|
|
disabled={isFullyCorrect}
|
|
/>
|
|
)}
|
|
|
|
{problem.answer.kind === "ratio" && (() => {
|
|
const ratioParts = (problem.answer as { kind: "ratio"; parts: number[] }).parts;
|
|
return (
|
|
<RatioInput
|
|
parts={
|
|
userAnswer.ratioParts
|
|
? JSON.parse(userAnswer.ratioParts)
|
|
: ratioParts.map(() => "")
|
|
}
|
|
onChange={(i, v) => {
|
|
const current = userAnswer.ratioParts
|
|
? JSON.parse(userAnswer.ratioParts)
|
|
: ratioParts.map(() => "");
|
|
current[i] = v;
|
|
setUserAnswer((a) => ({
|
|
...a,
|
|
ratioParts: JSON.stringify(current),
|
|
ratio: current.join(":"),
|
|
}));
|
|
}}
|
|
disabled={isFullyCorrect}
|
|
/>
|
|
);
|
|
})()}
|
|
|
|
{problem.answer.kind === "standardForm" && (
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="text"
|
|
inputMode="decimal"
|
|
value={userAnswer.coefficient || ""}
|
|
onChange={(e) => setUserAnswer((a) => ({ ...a, coefficient: e.target.value }))}
|
|
disabled={isFullyCorrect}
|
|
className="w-16 rounded-lg border border-border bg-surface px-2 py-2 text-center font-bold focus:border-unit-2 focus:outline-none"
|
|
placeholder="?"
|
|
aria-label="Coefficient"
|
|
/>
|
|
<span className="text-lg font-bold">x 10</span>
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={userAnswer.exponent || ""}
|
|
onChange={(e) => setUserAnswer((a) => ({ ...a, exponent: e.target.value }))}
|
|
disabled={isFullyCorrect}
|
|
className="w-12 -translate-y-2 rounded-lg border border-border bg-surface px-2 py-1 text-center text-sm font-bold focus:border-unit-2 focus:outline-none"
|
|
placeholder="?"
|
|
aria-label="Exponent"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="mb-4 flex flex-wrap justify-center gap-3">
|
|
{!isFullyCorrect && (
|
|
<>
|
|
<Button variant={unitColor} size="sm" onClick={checkAnswer}>
|
|
Check Answer
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onClick={nextHint}>
|
|
Hint
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => setShowSolution(true)}>
|
|
Show Solution
|
|
</Button>
|
|
</>
|
|
)}
|
|
{isFullyCorrect && (
|
|
<Button variant={unitColor} size="sm" onClick={() => generateNew(difficulty)}>
|
|
Next Problem
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Feedback */}
|
|
<AnimatePresence>
|
|
{result && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0 }}
|
|
className={`rounded-xl px-4 py-3 text-center text-sm font-medium ${
|
|
result.correct
|
|
? result.simplified
|
|
? "bg-correct-light text-correct"
|
|
: "bg-hint-light text-hint"
|
|
: "bg-incorrect-light text-incorrect"
|
|
}`}
|
|
>
|
|
{result.correct
|
|
? result.simplified
|
|
? "Correct!"
|
|
: "Correct, but can you simplify further?"
|
|
: result.message}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Hint */}
|
|
<AnimatePresence>
|
|
{showHint && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="mt-3 rounded-xl bg-hint-light px-4 py-3 text-sm text-hint"
|
|
>
|
|
{problem.hints[hintIndex]}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Solution */}
|
|
<AnimatePresence>
|
|
{showSolution && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
className="mt-3 space-y-2 rounded-xl bg-background p-4"
|
|
>
|
|
<p className="text-xs font-semibold uppercase text-muted">Solution</p>
|
|
{problem.steps.map((step, i) => (
|
|
<div key={i} className="flex gap-2 text-sm">
|
|
<span className="shrink-0 font-bold text-muted">{i + 1}.</span>
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
<div>{step.explanation}</div>
|
|
{step.math && <MathDisplay math={step.math} className="text-lg" />}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</Card>
|
|
);
|
|
}
|