Initial Commit

This commit is contained in:
2026-03-01 18:50:29 -04:00
parent 261c52d602
commit 364facd9f0
69 changed files with 7829 additions and 87 deletions

View File

@@ -0,0 +1,104 @@
"use client";
import { cn } from "@/lib/utils";
interface FractionInputProps {
numerator: string;
denominator: string;
onNumeratorChange: (value: string) => void;
onDenominatorChange: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function FractionInput({
numerator,
denominator,
onNumeratorChange,
onDenominatorChange,
disabled = false,
className,
}: FractionInputProps) {
return (
<div className={cn("inline-flex flex-col items-center gap-0.5", className)}>
<input
type="text"
inputMode="numeric"
pattern="[0-9-]*"
value={numerator}
onChange={(e) => onNumeratorChange(e.target.value)}
disabled={disabled}
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
placeholder="?"
aria-label="Numerator"
/>
<div className="h-0.5 w-16 bg-foreground" />
<input
type="text"
inputMode="numeric"
pattern="[0-9-]*"
value={denominator}
onChange={(e) => onDenominatorChange(e.target.value)}
disabled={disabled}
className="w-16 rounded-lg border border-border bg-surface px-2 py-1.5 text-center text-lg font-bold focus:border-unit-1 focus:outline-none"
placeholder="?"
aria-label="Denominator"
/>
</div>
);
}
interface DecimalInputProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
}
export function DecimalInput({ value, onChange, disabled, className }: DecimalInputProps) {
return (
<input
type="text"
inputMode="decimal"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={cn(
"w-28 rounded-lg border border-border bg-surface px-3 py-2 text-center text-lg font-bold focus:border-unit-2 focus:outline-none",
className,
)}
placeholder="?"
aria-label="Answer"
/>
);
}
interface RatioInputProps {
parts: string[];
onChange: (index: number, value: string) => void;
disabled?: boolean;
className?: string;
}
export function RatioInput({ parts, onChange, disabled, className }: RatioInputProps) {
return (
<div className={cn("inline-flex items-center gap-1", className)}>
{parts.map((p, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-lg font-bold text-muted">:</span>}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={p}
onChange={(e) => onChange(i, e.target.value)}
disabled={disabled}
className="w-14 rounded-lg border border-border bg-surface px-2 py-2 text-center text-lg font-bold focus:border-unit-4 focus:outline-none"
placeholder="?"
aria-label={`Part ${i + 1}`}
/>
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,308 @@
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { MathProblem, Difficulty } from "@/lib/problems/types";
import { checkFractionAnswer, checkDecimalAnswer, checkRatioAnswer, checkIntegerAnswer, 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 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",
};
export function PracticeSection({ title, generator, unitColor = "unit-1" }: PracticeSectionProps) {
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 generateNew = useCallback((diff: Difficulty) => {
setProblem(generator(diff));
setUserAnswer({});
setResult(null);
setShowHint(false);
setHintIndex(0);
setShowSolution(false);
}, [generator]);
function checkAnswer() {
const answer = problem.answer;
let res: AnswerResult;
switch (answer.kind) {
case "fraction": {
const num = parseInt(userAnswer.numerator || "0");
const den = parseInt(userAnswer.denominator || "0");
if (isNaN(num) || isNaN(den)) {
res = { correct: false, message: "Enter valid numbers" };
} else {
res = checkFractionAnswer(num, den, answer.numerator, answer.denominator);
}
break;
}
case "decimal": {
const val = parseFloat(userAnswer.value || "");
if (isNaN(val)) {
res = { correct: false, message: "Enter a valid number" };
} else {
res = checkDecimalAnswer(val, answer.value);
}
break;
}
case "integer": {
const val = parseInt(userAnswer.value || "");
if (isNaN(val)) {
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 = parseFloat(userAnswer.coefficient || "");
const exp = parseInt(userAnswer.exponent || "");
if (isNaN(coeff) || isNaN(exp)) {
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 (hintIndex < problem.hints.length - 1) {
setHintIndex((i) => i + 1);
}
setShowHint(true);
}
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={result?.correct === true}
/>
)}
{(problem.answer.kind === "decimal" || problem.answer.kind === "integer") && (
<DecimalInput
value={userAnswer.value || ""}
onChange={(v) => setUserAnswer((a) => ({ ...a, value: v }))}
disabled={result?.correct === true}
/>
)}
{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={result?.correct === true}
/>
);
})()}
{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={result?.correct === true}
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={result?.correct === true}
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">
{!result?.correct && (
<>
<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>
</>
)}
{result?.correct && (
<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>
<span>{step.explanation}</span>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</Card>
);
}