From 7db6fc0bab3edf03f65914803922604f09797e11 Mon Sep 17 00:00:00 2001 From: warringtond Date: Sun, 19 Apr 2026 20:28:12 -0400 Subject: [PATCH] fractions add/sub fix --- .codex | 0 components/practice/practice-section.tsx | 97 +++++++++++++++----- lib/math/validation.ts | 19 ++++ lib/problems/generators/fraction-problems.ts | 51 +++++++--- lib/problems/generators/ratio-problems.ts | 2 +- 5 files changed, 134 insertions(+), 35 deletions(-) create mode 100644 .codex diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/components/practice/practice-section.tsx b/components/practice/practice-section.tsx index 0950ea8..3fb387c 100644 --- a/components/practice/practice-section.tsx +++ b/components/practice/practice-section.tsx @@ -1,12 +1,22 @@ "use client"; -import { useState, useCallback } from "react"; +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, type AnswerResult } from "@/lib/math/validation"; +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 { @@ -24,7 +34,18 @@ const activeDifficultyStyle: Record = { "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(null); const [difficulty, setDifficulty] = useState(1); const [problem, setProblem] = useState(() => generator(1)); const [userAnswer, setUserAnswer] = useState>({}); @@ -33,15 +54,39 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac 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(generator(diff)); + setProblem(generateUniqueProblem(diff)); setUserAnswer({}); setResult(null); setShowHint(false); setHintIndex(0); setShowSolution(false); - }, [generator]); + }, [generateUniqueProblem]); function checkAnswer() { const answer = problem.answer; @@ -49,9 +94,9 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac switch (answer.kind) { case "fraction": { - const num = parseInt(userAnswer.numerator || "0"); - const den = parseInt(userAnswer.denominator || "0"); - if (isNaN(num) || isNaN(den)) { + 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); @@ -59,8 +104,8 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac break; } case "decimal": { - const val = parseFloat(userAnswer.value || ""); - if (isNaN(val)) { + const val = parseStrictSignedDecimal(userAnswer.value || ""); + if (val === null) { res = { correct: false, message: "Enter a valid number" }; } else { res = checkDecimalAnswer(val, answer.value); @@ -68,8 +113,8 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac break; } case "integer": { - const val = parseInt(userAnswer.value || ""); - if (isNaN(val)) { + const val = parseStrictSignedInt(userAnswer.value || ""); + if (val === null) { res = { correct: false, message: "Enter a valid number" }; } else { res = checkIntegerAnswer(val, answer.value); @@ -86,9 +131,9 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac break; } case "standardForm": { - const coeff = parseFloat(userAnswer.coefficient || ""); - const exp = parseInt(userAnswer.exponent || ""); - if (isNaN(coeff) || isNaN(exp)) { + 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 && @@ -111,10 +156,13 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac } function nextHint() { + if (!showHint) { + setShowHint(true); + return; + } if (hintIndex < problem.hints.length - 1) { setHintIndex((i) => i + 1); } - setShowHint(true); } const promptHtml = katex.renderToString(problem.prompt, { throwOnError: false, displayMode: true }); @@ -162,7 +210,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac denominator={userAnswer.denominator || ""} onNumeratorChange={(v) => setUserAnswer((a) => ({ ...a, numerator: v }))} onDenominatorChange={(v) => setUserAnswer((a) => ({ ...a, denominator: v }))} - disabled={result?.correct === true} + disabled={isFullyCorrect} /> )} @@ -170,7 +218,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac setUserAnswer((a) => ({ ...a, value: v }))} - disabled={result?.correct === true} + disabled={isFullyCorrect} /> )} @@ -194,7 +242,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac ratio: current.join(":"), })); }} - disabled={result?.correct === true} + disabled={isFullyCorrect} /> ); })()} @@ -206,7 +254,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac inputMode="decimal" value={userAnswer.coefficient || ""} onChange={(e) => setUserAnswer((a) => ({ ...a, coefficient: e.target.value }))} - disabled={result?.correct === true} + 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" @@ -217,7 +265,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac inputMode="numeric" value={userAnswer.exponent || ""} onChange={(e) => setUserAnswer((a) => ({ ...a, exponent: e.target.value }))} - disabled={result?.correct === true} + 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" @@ -228,7 +276,7 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac {/* Actions */}
- {!result?.correct && ( + {!isFullyCorrect && ( <> )} - {result?.correct && ( + {isFullyCorrect && ( @@ -297,7 +345,10 @@ export function PracticeSection({ title, generator, unitColor = "unit-1" }: Prac {problem.steps.map((step, i) => (
{i + 1}. - {step.explanation} +
+
{step.explanation}
+ {step.math && } +
))} diff --git a/lib/math/validation.ts b/lib/math/validation.ts index bf6a3c4..2f374c6 100644 --- a/lib/math/validation.ts +++ b/lib/math/validation.ts @@ -15,6 +15,20 @@ export function parseStrictInt(input: string): number | null { return parseInt(s, 10); } +/** Parse a strict signed integer string. Reject decimals, scientific notation, and trailing text. */ +export function parseStrictSignedInt(input: string): number | null { + const s = input.trim(); + if (!/^-?\d+$/.test(s)) return null; + return parseInt(s, 10); +} + +/** Parse a strict signed decimal string. Reject scientific notation and trailing text. */ +export function parseStrictSignedDecimal(input: string): number | null { + const s = input.trim(); + if (!/^-?\d+(\.\d+)?$/.test(s)) return null; + return parseFloat(s); +} + export type AnswerResult = | { correct: true; simplified: boolean } | { correct: false; message: string }; @@ -37,6 +51,11 @@ export function checkFractionAnswer( return { correct: false, message: "That's not quite right. Try again!" }; } + // Any non-zero denominator gives the same value for zero, so don't force 0/1 in the UI. + if (userNum === 0) { + return { correct: true, simplified: true }; + } + const isSimplified = gcd(Math.abs(userNum), Math.abs(userDen)) === 1; if (requireSimplified && !isSimplified) { diff --git a/lib/problems/generators/fraction-problems.ts b/lib/problems/generators/fraction-problems.ts index bd38ef6..55a1b1a 100644 --- a/lib/problems/generators/fraction-problems.ts +++ b/lib/problems/generators/fraction-problems.ts @@ -34,22 +34,51 @@ export function generateFractionAddSubtract(difficulty: Difficulty): MathProblem n2 = randomInt(1, d2 - 1); } - const [ansNum, ansDen] = operation === "add" - ? frac.add(n1, d1, n2, d2) - : frac.subtract(n1, d1, n2, d2); - const prompt = `\\frac{${n1}}{${d1}} ${op} \\frac{${n2}}{${d2}}`; - const commonDen = frac.lcm(d1, d2); + const convertedN1 = n1 * (commonDen / d1); + const convertedN2 = n2 * (commonDen / d2); + const rawNum = operation === "add" + ? convertedN1 + convertedN2 + : convertedN1 - convertedN2; + const [ansNum, ansDen] = frac.simplify(rawNum, commonDen); + const steps = d1 === d2 ? [ - { explanation: "Denominators are the same, so just " + (operation === "add" ? "add" : "subtract") + " the numerators" }, - { explanation: `${n1} ${op} ${n2} = ${operation === "add" ? n1 + n2 : n1 - n2}`, math: `\\frac{${operation === "add" ? n1 + n2 : n1 - n2}}{${d1}}` }, + { + explanation: `The denominators already match, so keep ${d1} as the denominator.`, + math: `\\frac{${n1}}{${d1}} ${op} \\frac{${n2}}{${d2}}`, + }, + { + explanation: `${operation === "add" ? "Add" : "Subtract"} the numerators and keep the denominator the same.`, + math: `\\frac{${n1}}{${d1}} ${op} \\frac{${n2}}{${d2}} = \\frac{${rawNum}}{${d1}}`, + }, + ...(ansNum !== rawNum || ansDen !== d1 + ? [{ + explanation: "Simplify the fraction.", + math: `\\frac{${rawNum}}{${d1}} = \\frac{${ansNum}}{${ansDen}}`, + }] + : []), ] : [ - { explanation: `Find the common denominator: LCM(${d1}, ${d2}) = ${commonDen}` }, - { explanation: `Convert: ${n1}×${commonDen / d1}/${d1}×${commonDen / d1} and ${n2}×${commonDen / d2}/${d2}×${commonDen / d2}` }, - { explanation: `${op === "+" ? "Add" : "Subtract"} numerators`, math: `\\frac{${ansNum}}{${ansDen}}` }, + { + explanation: `Find the lowest common denominator: LCM(${d1}, ${d2}) = ${commonDen}.`, + math: `\\text{LCD} = ${commonDen}`, + }, + { + explanation: "Rewrite both fractions with that denominator.", + math: `\\frac{${n1}}{${d1}} = \\frac{${convertedN1}}{${commonDen}} \\quad \\frac{${n2}}{${d2}} = \\frac{${convertedN2}}{${commonDen}}`, + }, + { + explanation: `${operation === "add" ? "Add" : "Subtract"} the numerators and keep the common denominator.`, + math: `\\frac{${convertedN1}}{${commonDen}} ${op} \\frac{${convertedN2}}{${commonDen}} = \\frac{${rawNum}}{${commonDen}}`, + }, + ...(ansNum !== rawNum || ansDen !== commonDen + ? [{ + explanation: "Simplify the fraction.", + math: `\\frac{${rawNum}}{${commonDen}} = \\frac{${ansNum}}{${ansDen}}`, + }] + : []), ]; return { @@ -58,7 +87,7 @@ export function generateFractionAddSubtract(difficulty: Difficulty): MathProblem answer: { kind: "fraction", numerator: ansNum, denominator: ansDen }, hints: [ d1 === d2 ? "The denominators are already the same!" : "Find a common denominator first", - d1 !== d2 ? `Try using the Butterfly Method: cross multiply` : `Just ${operation} the numerators`, + d1 !== d2 ? "Rewrite both fractions using that denominator" : `Just ${operation} the numerators`, ], steps, }; diff --git a/lib/problems/generators/ratio-problems.ts b/lib/problems/generators/ratio-problems.ts index 69e5dac..776e831 100644 --- a/lib/problems/generators/ratio-problems.ts +++ b/lib/problems/generators/ratio-problems.ts @@ -93,7 +93,7 @@ export function generateRatioWordProblem(difficulty: Difficulty): MathProblem { return { id: nextId(), - prompt: `\\text{${ctx.item.charAt(0).toUpperCase() + ctx.item.slice(1)} were shared between ${ctx.nameA} and ${ctx.nameB} in the ratio ${a}:${b}. If ${bigger} received ${extraAmount} more ${ctx.item}, find the total shared.}`, + prompt: `\\begin{array}{l}\\text{${ctx.item.charAt(0).toUpperCase() + ctx.item.slice(1)} were shared between ${ctx.nameA} and ${ctx.nameB}}\\\\\\text{in the ratio ${a}:${b}. If ${bigger} received ${extraAmount} more ${ctx.item}, find the total shared.}\\end{array}`, answer: { kind: "integer", value: total }, hints: [ `The difference in ratio parts is ${biggerRatio} - ${smallerRatio} = ${diff}`,