diff --git a/apps/web/app/[locale]/practice/page.tsx b/apps/web/app/[locale]/practice/page.tsx index 5edaa78d..559d1f66 100644 --- a/apps/web/app/[locale]/practice/page.tsx +++ b/apps/web/app/[locale]/practice/page.tsx @@ -7,11 +7,13 @@ import { getProblemsByTopic, type Problem, } from '@nextcalc/math-engine/problems'; +import { randomSeedString } from '@nextcalc/math-engine/problems/templates'; import { Play, Settings, Target, Timer, TrendingUp, Zap } from 'lucide-react'; import { m, useReducedMotion } from 'motion/react'; +import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import type { CSSProperties } from 'react'; -import { useActionState, useCallback, useEffect, useRef, useState } from 'react'; +import { Suspense, useActionState, useCallback, useEffect, useRef, useState } from 'react'; import type { PracticeAttemptResult, PracticeSessionResult, @@ -23,6 +25,7 @@ import { startPracticeSession, } from '@/app/actions/practice'; import type { ActionResult } from '@/app/actions/problems'; +import { DrillMode } from '@/components/math/drill-mode'; import { type PracticeMetrics, PracticeMode } from '@/components/math/practice-mode'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -291,8 +294,9 @@ function StatCard({ data }: { data: StatCardData }) { * - Semantic form structure with associated labels * - Screen reader announcements via aria-live on the empty-state region */ -export default function PracticePage() { +function PracticePageInner() { const t = useTranslations('practice'); + const searchParams = useSearchParams(); const [isConfiguring, setIsConfiguring] = useState(true); const [problems, setProblems] = useState>([]); const [config, setConfig] = useState({ @@ -321,6 +325,49 @@ export default function PracticePage() { const prefersReduced = useReducedMotion() ?? false; + // ── Infinite-drill URL state (?mode=drill&template=…&seed=…) ──────────── + const drillActive = searchParams.get('mode') === 'drill'; + const drillTemplate = searchParams.get('template') ?? 'linear-equation-basic'; + const drillSeed = searchParams.get('seed'); + + // Mint a seed into the URL when drill mode is entered without one, so the + // address bar is always shareable and reproduces this exact problem. + useEffect(() => { + if (drillActive && !drillSeed) { + const params = new URLSearchParams(searchParams.toString()); + params.set('template', drillTemplate); + params.set('seed', randomSeedString()); + window.history.replaceState(null, '', `?${params.toString()}`); + } + }, [drillActive, drillSeed, drillTemplate, searchParams]); + + // Shallow URL updates via the native History API (Next 16 keeps + // useSearchParams in sync) — no server round-trip per problem. + const handleDrillChange = (next: { templateId: string; seed: string }) => { + const params = new URLSearchParams(searchParams.toString()); + params.set('mode', 'drill'); + params.set('template', next.templateId); + params.set('seed', next.seed); + window.history.replaceState(null, '', `?${params.toString()}`); + }; + + const handleEnterDrill = () => { + const params = new URLSearchParams(searchParams.toString()); + params.set('mode', 'drill'); + params.set('template', drillTemplate); + params.set('seed', randomSeedString()); + window.history.pushState(null, '', `?${params.toString()}`); + }; + + const handleExitDrill = () => { + const params = new URLSearchParams(searchParams.toString()); + params.delete('mode'); + params.delete('template'); + params.delete('seed'); + const query = params.toString(); + window.history.pushState(null, '', query ? `?${query}` : window.location.pathname); + }; + // Load problems from math-engine's in-memory problem database. // Applies topic and difficulty filters from the session config, then // randomly samples up to questionCount problems so every session feels fresh. @@ -410,6 +457,25 @@ export default function PracticePage() { [completeSessionAction], ); + // ── Infinite drill mode ───────────────────────────────────────────────── + if (drillActive) { + return ( +
+ +
+ {drillSeed && ( + + )} +
+
+ ); + } + // ── Empty state (no matching problems) ───────────────────────────────────── if (!isConfiguring) { if (problems.length === 0) { @@ -520,6 +586,58 @@ export default function PracticePage() { {/* Configuration Form */}
+ {/* Infinite Drill entry */} + + + +
+ +
+
{t('drill.title')}
+
{t('drill.description')}
+
+
+ +
+
+
+ ); } + +/** + * Page shell: useSearchParams (drill deep-links) requires a Suspense + * boundary for static prerendering. + */ +export default function PracticePage() { + return ( + + + + ); +} diff --git a/apps/web/app/actions/practice.ts b/apps/web/app/actions/practice.ts index 888ad173..6837f677 100644 --- a/apps/web/app/actions/practice.ts +++ b/apps/web/app/actions/practice.ts @@ -199,6 +199,37 @@ export async function completePracticeSession( }, }); + // Topic-progress wiring for drill sessions (generated problems have no + // Attempt rows, so aggregates land here). Best-effort: silently skipped + // when no Topic row matches the slug. + if (data.correctCount !== undefined && data.correctCount > 0 && data.topicSlug) { + const topic = await prisma.topic.findUnique({ + where: { slug: data.topicSlug }, + select: { id: true }, + }); + if (topic) { + await prisma.topicProgress.upsert({ + where: { + userProgressId_topicId: { + userProgressId: practiceSession.userProgressId, + topicId: topic.id, + }, + }, + update: { + problemsSolved: { increment: data.correctCount }, + timeSpent: { increment: data.totalTime }, + lastPracticed: new Date(), + }, + create: { + userProgressId: practiceSession.userProgressId, + topicId: topic.id, + problemsSolved: data.correctCount, + timeSpent: data.totalTime, + }, + }); + } + } + return { success: true, data: { diff --git a/apps/web/app/actions/problems.ts b/apps/web/app/actions/problems.ts index 87aa4faa..87d0a491 100644 --- a/apps/web/app/actions/problems.ts +++ b/apps/web/app/actions/problems.ts @@ -7,6 +7,7 @@ * Each action validates input via Zod, checks auth, and returns a typed result. */ +import { checkEquivalence, normalizeAnswerExpression } from '@nextcalc/math-engine/equivalence'; import { revalidatePath } from 'next/cache'; import { auth } from '@/auth'; import { prisma } from '@/lib/prisma'; @@ -16,6 +17,45 @@ import { HintRequestSchema, } from '@/lib/validations/learning'; +/** + * Grade a submitted answer against an expected test-case value. + * Exact-match first (original behavior preserved), then CAS equivalence + * so mathematically equivalent forms (e.g. "x=2" vs "2", "(x-2)*(x-3)" + * vs "x^2-5*x+6", "1/2" vs "0.5") are accepted. The equivalence checker + * is conservative — it never claims equivalence it cannot support — and + * unparseable expected values degrade gracefully to exact matching. + * + * `allowConstantOfIntegration` opts into stripping a trailing "+ C" / "+ c" + * from both sides — valid ONLY for indefinite-integral problems. Applied + * unconditionally, it would silently drop a legitimate trailing "+ c" term + * from a non-integral expected value (e.g. a perimeter formula "a + b + c") + * and would forgive a student's spurious "+ C" on a non-integral answer + * (e.g. a derivative), so callers must gate it on the problem's actual type. + */ +function answerMatchesExpected( + answer: string, + expected: string, + allowConstantOfIntegration = false, +): boolean { + // (a) Trimmed case-insensitive equality — the original grading rule + if (expected.trim().toLowerCase() === answer.trim().toLowerCase()) { + return true; + } + // (b)+(c) Numeric/symbolic equivalence (checkEquivalence covers both: + // constant expressions compare numerically, variable expressions via + // symbolic simplification with seeded numeric probing). + try { + const normalizedAnswer = normalizeAnswerExpression(answer, allowConstantOfIntegration); + const normalizedExpected = normalizeAnswerExpression(expected, allowConstantOfIntegration); + if (normalizedAnswer.length === 0 || normalizedExpected.length === 0) { + return false; + } + return checkEquivalence(normalizedAnswer, normalizedExpected).equivalent; + } catch { + return false; + } +} + // --------------------------------------------------------------------------- // Shared types // --------------------------------------------------------------------------- @@ -72,7 +112,7 @@ export async function submitAnswer( where: { isHidden: false }, orderBy: { order: 'asc' }, }, - topics: true, + topics: { include: { topic: { select: { slug: true } } } }, }, }); @@ -80,11 +120,18 @@ export async function submitAnswer( return { success: false, error: 'Problem not found' }; } - // Validate answer against test cases + // Only indefinite-integral problems may have a legitimate trailing + // "+ C" — gate the equivalence checker's constant-of-integration + // strip on that, never apply it generically (see answerMatchesExpected). + const isIndefiniteIntegral = problem.topics.some( + (pt) => pt.topic.slug === 'indefinite-integrals', + ); + + // Validate answer against test cases (exact match or CAS-equivalent form) const isCorrect = problem.testCases.length > 0 - ? problem.testCases.some( - (tc) => tc.expected.trim().toLowerCase() === data.answer.trim().toLowerCase(), + ? problem.testCases.some((tc) => + answerMatchesExpected(data.answer, tc.expected, isIndefiniteIntegral), ) : true; diff --git a/apps/web/components/calculator/solver-panel.tsx b/apps/web/components/calculator/solver-panel.tsx index 7eb9994e..83365be5 100644 --- a/apps/web/components/calculator/solver-panel.tsx +++ b/apps/web/components/calculator/solver-panel.tsx @@ -19,6 +19,7 @@ import { AnimatePresence, m } from 'motion/react'; import { useTranslations } from 'next-intl'; import type { KeyboardEvent } from 'react'; import { useCallback, useId, useState, useTransition } from 'react'; +import { CheckWorkPanel } from '@/components/math/check-work-panel'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -101,6 +102,8 @@ interface RenderedSolution { readonly steps: ReadonlyArray; readonly finalLatex: string; readonly timeMs: number; + /** Raw parseable expression of the result — enables "Check my work" */ + readonly finalRaw?: string; } // ============================================================================ @@ -1168,6 +1171,14 @@ function ResultsPanel({ solution, onCopyAnswer, copied }: ResultsPanelProps) {
+ + {/* "Check my work" — verify an equivalent form against the answer */} + {solution.finalRaw && ( + + )} ); } @@ -1372,6 +1383,7 @@ export function SolverPanel() { steps, finalLatex: `${resultLatex} + C`, timeMs: elapsed, + finalRaw: resultStr, }); return; } @@ -1397,12 +1409,55 @@ export function SolverPanel() { // Build final answer LaTeX const finalLatex = buildFinalLatex(stepSolution.answer, varName); + // Raw parseable expression for the "Check my work" panel. + // For equations the canonical must be the actual solved root — a + // student typing their correct numeric answer (e.g. "3") can never + // equal the zero-form LHS − RHS, which is a function of the + // variable, not a number. Only offer the panel when there is + // exactly one real root: CheckWorkPanel compares against a single + // canonical value, so it cannot represent "equivalent to any of + // several roots" without rejecting an otherwise-correct answer. + // For simplify/derivative the canonical is the resulting expression. + let finalRaw: string | null = null; + try { + if (mode === 'equation') { + const solutions = stepSolution.answer; + if (Array.isArray(solutions) && solutions.length === 1) { + const value = (solutions[0] as { value: unknown } | undefined)?.value; + if (typeof value === 'number' && Number.isFinite(value)) { + finalRaw = String(value); + } else if ( + value !== null && + typeof value === 'object' && + 'real' in value && + 'imag' in value && + Math.abs((value as { imag: number }).imag) < 1e-9 + ) { + finalRaw = String((value as { real: number }).real); + } + } + } else { + const answer = stepSolution.answer; + if ( + answer !== null && + typeof answer === 'object' && + !Array.isArray(answer) && + '_brand' in answer + ) { + finalRaw = symbolic.astToString(answer); + } + } + } catch { + finalRaw = null; + } + setSolution({ problem: validated, mode, steps: renderedSteps, finalLatex, timeMs: elapsed, + ...(finalRaw ? { finalRaw } : {}), }); } catch (err) { const msg = err instanceof Error ? err.message : 'An unexpected error occurred.'; diff --git a/apps/web/components/math/check-work-panel.test.tsx b/apps/web/components/math/check-work-panel.test.tsx new file mode 100644 index 00000000..a972b3be --- /dev/null +++ b/apps/web/components/math/check-work-panel.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { CheckWorkPanel } from './check-work-panel'; + +// Query by placeholder: happy-dom cannot resolve label[for] against React 19 +// useId identifiers (guillemet characters), so label-text queries miss. +async function typeAndCheck(input: string) { + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText('inputPlaceholder'), input); + await user.click(screen.getByRole('button', { name: 'check' })); +} + +describe('CheckWorkPanel', () => { + it('reports equivalence for an equivalent factored form (numeric caption)', async () => { + render(); + await typeAndCheck('(x - 2)*(x - 3)'); + + expect(await screen.findByText('equivalent')).toBeInTheDocument(); + expect(screen.getByText('verifiedNumerically')).toBeInTheDocument(); + }); + + it('reports symbolic verification when the difference cancels exactly', async () => { + render(); + await typeAndCheck('x - x'); + + expect(await screen.findByText('equivalent')).toBeInTheDocument(); + expect(screen.getByText('verifiedSymbolically')).toBeInTheDocument(); + }); + + it('reports non-equivalence for a wrong form', async () => { + render(); + await typeAndCheck('(x - 2)*(x - 4)'); + + expect(await screen.findByText('notEquivalent')).toBeInTheDocument(); + expect(screen.queryByText('equivalent')).not.toBeInTheDocument(); + }); + + it('reports a parse error for garbage input', async () => { + render(); + await typeAndCheck('(x +'); + + expect(await screen.findByText('parseError')).toBeInTheDocument(); + }); + + it('clears the previous verdict when the input changes', async () => { + render(); + await typeAndCheck('x + 1'); + expect(await screen.findByText('equivalent')).toBeInTheDocument(); + + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText('inputPlaceholder'), '9'); + expect(screen.queryByText('equivalent')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/math/check-work-panel.tsx b/apps/web/components/math/check-work-panel.tsx new file mode 100644 index 00000000..f83a1966 --- /dev/null +++ b/apps/web/components/math/check-work-panel.tsx @@ -0,0 +1,177 @@ +'use client'; + +import type { EquivalenceResult } from '@nextcalc/math-engine/equivalence'; +import { AlertCircle, CheckCircle2, Loader2, ShieldCheck, XCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import type { KeyboardEvent } from 'react'; +import { useId, useState, useTransition } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; + +export interface CheckWorkPanelProps { + /** Canonical parseable expression the student's input is verified against */ + canonical: string; + /** Optional heading override (defaults to the checkWork.title message) */ + label?: string; + /** + * Allow a trailing "+ C" / "+ c" to be stripped from the student's input + * before comparison. Valid ONLY when `canonical` is an indefinite-integral + * antiderivative — passing this for any other mode (equation, simplify, + * derivative) would forgive a spurious constant the student shouldn't + * have added. Defaults to false. + */ + allowConstantOfIntegration?: boolean; +} + +/** Visual state derived from an equivalence result */ +type PanelStatus = 'equivalent' | 'not-equivalent' | 'parse-error' | 'inconclusive'; + +function statusFor(result: EquivalenceResult): PanelStatus { + if (result.equivalent) return 'equivalent'; + if (result.reason === 'parse-error') return 'parse-error'; + if (result.reason === 'inconclusive') return 'inconclusive'; + return 'not-equivalent'; +} + +const STATUS_STYLES: Record = { + equivalent: 'bg-green-500/10 border-green-500/30', + 'not-equivalent': 'bg-red-500/10 border-red-500/30', + 'parse-error': 'bg-amber-500/10 border-amber-500/30', + inconclusive: 'bg-amber-500/10 border-amber-500/30', +}; + +/** + * CheckWorkPanel — "Check my work" self-verification widget. + * + * The student enters their own form of an answer and the panel verifies + * mathematical equivalence against the canonical expression via the + * math-engine equivalence checker (symbolic diff-is-zero with a + * deterministic seeded numeric-probing fallback). This is a study aid, + * separate from graded submission — it never records an attempt. + */ +export function CheckWorkPanel({ + canonical, + label, + allowConstantOfIntegration = false, +}: CheckWorkPanelProps) { + const t = useTranslations('checkWork'); + const [input, setInput] = useState(''); + const [result, setResult] = useState(null); + const [isPending, startTransition] = useTransition(); + const inputId = useId(); + const statusId = useId(); + + const handleCheck = () => { + const candidate = input.trim(); + if (candidate.length === 0) return; + startTransition(async () => { + // Dynamic import keeps the CAS out of the initial bundle + const { checkEquivalence, normalizeAnswerExpression } = await import( + '@nextcalc/math-engine/equivalence' + ); + setResult( + checkEquivalence( + normalizeAnswerExpression(candidate, allowConstantOfIntegration), + canonical, + ), + ); + }); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !isPending) { + event.preventDefault(); + handleCheck(); + } + }; + + const status = result ? statusFor(result) : null; + + return ( +
+
+
+

{t('description')}

+ +
+ + { + setInput(event.target.value); + setResult(null); + }} + onKeyDown={handleKeyDown} + placeholder={t('inputPlaceholder')} + className="font-mono text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + spellCheck={false} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + aria-describedby={result ? statusId : undefined} + /> + +
+ + {result && status && ( +
+ {status === 'equivalent' && ( +
+ )} +
+ ); +} diff --git a/apps/web/components/math/drill-mode.test.tsx b/apps/web/components/math/drill-mode.test.tsx new file mode 100644 index 00000000..c332080e --- /dev/null +++ b/apps/web/components/math/drill-mode.test.tsx @@ -0,0 +1,139 @@ +import { registerAllTemplates, templateEngine } from '@nextcalc/math-engine/problems/templates'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { DrillMode } from './drill-mode'; + +// Server actions touch auth/prisma — mock them (drill must work signed-out) +vi.mock('@/app/actions/practice', () => ({ + startPracticeSession: vi.fn(async () => ({ + success: false, + error: 'Sign in to save practice progress', + })), + completePracticeSession: vi.fn(async () => ({ + success: false, + error: 'Sign in to save practice results', + })), +})); + +// KaTeX renders async in happy-dom — render raw expressions instead +vi.mock('@/components/ui/math-renderer', () => ({ + MathRenderer: ({ expression }: { expression: string }) => {expression}, +})); + +const TEMPLATE_ID = 'linear-equation-basic'; +const SEED = 'abc123'; + +function narrowParams(instance: { parameters: Record }) { + const { a, b, c } = instance.parameters as { a: number; b: number; c: number }; + return { a, b, c }; +} + +beforeAll(() => { + registerAllTemplates(); +}); + +describe('DrillMode', () => { + it('renders the same statement for the same template+seed (deterministic)', () => { + const noop = () => {}; + const expected = templateEngine.generate(TEMPLATE_ID, SEED); + + const first = render( + , + ); + const statementA = first.container.textContent; + expect(statementA).toContain(`${narrowParams(expected).a}x + ${narrowParams(expected).b}`); + first.unmount(); + + const second = render( + , + ); + expect(second.container.textContent).toBe(statementA); + }); + + it('accepts an equivalent (unreduced) expression form of the answer', async () => { + const user = userEvent.setup(); + const noop = () => {}; + const instance = templateEngine.generate(TEMPLATE_ID, SEED); + const { a, b, c } = narrowParams(instance); + + render(); + + // Answer with the raw expression (c - b)/a instead of the reduced value + await user.type(screen.getByLabelText('drill.answerLabel'), `(${c} - ${b})/${a}`); + await user.click(screen.getByRole('button', { name: /drill\.checkAnswer/ })); + + expect(await screen.findByText('drill.correct')).toBeInTheDocument(); + expect(screen.queryByText('drill.incorrect')).not.toBeInTheDocument(); + }); + + it('rejects a wrong answer and surfaces the matching common-mistake explanation', async () => { + const user = userEvent.setup(); + const noop = () => {}; + const instance = templateEngine.generate(TEMPLATE_ID, SEED); + const { a, b, c } = narrowParams(instance); + // The classic sign mistake the template's detector looks for: (c + b)/a + const wrongAnswer = String((c + b) / a); + + render(); + + await user.type(screen.getByLabelText('drill.answerLabel'), wrongAnswer); + await user.click(screen.getByRole('button', { name: /drill\.checkAnswer/ })); + + expect(await screen.findByText('drill.incorrect')).toBeInTheDocument(); + expect(screen.getByText(/added instead of subtracted/i)).toBeInTheDocument(); + }); + + it('rejects an off-by-sign answer without a matching detector', async () => { + const user = userEvent.setup(); + const noop = () => {}; + const instance = templateEngine.generate(TEMPLATE_ID, SEED); + const { a, b, c } = narrowParams(instance); + const negated = String(-((c - b) / a)); + + render(); + + await user.type(screen.getByLabelText('drill.answerLabel'), negated); + await user.click(screen.getByRole('button', { name: /drill\.checkAnswer/ })); + + expect(await screen.findByText('drill.incorrect')).toBeInTheDocument(); + }); + + it('Generate another mints a fresh crypto seed via onSeedChange', async () => { + const user = userEvent.setup(); + const onSeedChange = vi.fn(); + + render( + {}} + />, + ); + + await user.click(screen.getByRole('button', { name: /drill\.generateAnother/ })); + + expect(onSeedChange).toHaveBeenCalledTimes(1); + const next = onSeedChange.mock.calls[0]?.[0] as { templateId: string; seed: string }; + expect(next.templateId).toBe(TEMPLATE_ID); + expect(next.seed).not.toBe(SEED); + expect(next.seed).toMatch(/^[a-z2-9]{8}$/); + }); + + it('reveals hints progressively and counts them', async () => { + const user = userEvent.setup(); + const noop = () => {}; + const instance = templateEngine.generate(TEMPLATE_ID, SEED); + + render(); + + const hintButton = screen.getByRole('button', { name: /drill\.showHint/ }); + await user.click(hintButton); + expect(screen.getByText('drill.hintCost:{"count":1}')).toBeInTheDocument(); + expect(screen.getByText(instance.hints[0] ?? '')).toBeInTheDocument(); + + await user.click(hintButton); + expect(screen.getByText('drill.hintCost:{"count":2}')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/math/drill-mode.tsx b/apps/web/components/math/drill-mode.tsx new file mode 100644 index 00000000..0c782f74 --- /dev/null +++ b/apps/web/components/math/drill-mode.tsx @@ -0,0 +1,668 @@ +'use client'; + +import { checkGradedAnswer } from '@nextcalc/math-engine/equivalence'; +import { + allTemplates, + type Mistake, + type ProblemInstance, + type ProblemTemplate, + randomSeedString, + registerAllTemplates, + templateEngine, +} from '@nextcalc/math-engine/problems/templates'; +import { + AlertCircle, + CheckCircle2, + Flame, + Lightbulb, + Link2, + ListChecks, + RefreshCw, + Save, + Target, + Timer, + XCircle, +} from 'lucide-react'; +import { m, useReducedMotion } from 'motion/react'; +import { useTranslations } from 'next-intl'; +import type { KeyboardEvent, ReactNode } from 'react'; +import { useActionState, useEffect, useEffectEvent, useRef, useState } from 'react'; +import { + completePracticeSession, + type PracticeSessionResult, + type StartSessionResult, + startPracticeSession, +} from '@/app/actions/practice'; +import type { ActionResult } from '@/app/actions/problems'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { MathRenderer } from '@/components/ui/math-renderer'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; + +// Idempotent (Map.set) — safe on repeated module evaluation and under +// React StrictMode; generation itself is pure and seed-local. +registerAllTemplates(); + +export interface DrillModeProps { + /** Template id from the URL (?template=…) */ + templateId: string; + /** Seed from the URL (?seed=…) — same seed always reproduces the problem */ + seed: string; + /** Called when the drill needs a new URL state (new problem / template) */ + onSeedChange: (next: { templateId: string; seed: string }) => void; + /** Called when the user exits the drill back to session setup */ + onExit: () => void; +} + +interface DrillTally { + attempted: number; + correct: number; + streak: number; + bestStreak: number; +} + +const DIFFICULTY_VARIANTS = ['beginner', 'intermediate', 'advanced', 'expert', 'research'] as const; + +/** "linear-equation-basic" → "Linear Equation Basic" */ +function humanizeId(id: string): string { + return id + .split('-') + .map((word) => (word.length > 0 ? word[0]?.toUpperCase() + word.slice(1) : word)) + .join(' '); +} + +/** + * Group registered, machine-gradable templates by category (stable + * module-level data). Templates with no `canonical` (e.g. linear-inequality, + * prime-factorization) are excluded: their only fallback is exact + * case-insensitive string match against the raw LaTeX display answer, which + * no reasonable free-form student input can satisfy — offering them in the + * infinite-drill picker would make those drills effectively ungradeable. + */ +const TEMPLATE_GROUPS: ReadonlyArray<{ category: string; templates: ProblemTemplate[] }> = (() => { + const groups = new Map(); + for (const template of allTemplates) { + if (!template.canonical) continue; + const list = groups.get(template.category); + if (list) { + list.push(template); + } else { + groups.set(template.category, [template]); + } + } + return [...groups.entries()].map(([category, templates]) => ({ category, templates })); +})(); + +/** + * Render template text with embedded $…$ / $$…$$ LaTeX via KaTeX. + * The splitter keeps nested braces intact because the delimiter is `$`. + */ +function MathText({ text, className }: { text: string; className?: string }) { + const segments = text.split(/(\$\$[\s\S]+?\$\$|\$[^$]+\$)/g); + return ( + + {segments.map((segment, index) => { + const key = `${index}-${segment}`; + if (segment.startsWith('$$') && segment.endsWith('$$')) { + return ; + } + if (segment.startsWith('$') && segment.endsWith('$') && segment.length > 1) { + return ; + } + return {segment}; + })} + + ); +} + +/** Build the `completePracticeSession` FormData for one drill segment. */ +function buildCompleteFormData( + sessionId: string, + segment: DrillTally, + totalTimeSeconds: number, + topicSlug: string, +): FormData { + const attempted = Math.max(1, segment.attempted); + const fd = new FormData(); + fd.set('sessionId', sessionId); + fd.set('score', String(segment.correct / attempted)); + fd.set('accuracy', String(segment.correct / attempted)); + fd.set('bestStreak', String(segment.bestStreak)); + fd.set('totalTime', String(Math.round(totalTimeSeconds))); + fd.set('pointsEarned', String(10 * segment.correct)); + fd.set('correctCount', String(segment.correct)); + fd.set('topicSlug', topicSlug.toLowerCase()); + return fd; +} + +/** Small stat chip for the running tally row. */ +function StatChip({ icon, children }: { icon: ReactNode; children: ReactNode }) { + return ( + + {icon} + {children} + + ); +} + +// ─── Per-problem card (remounted via key on every new seed) ────────────────── + +interface DrillProblemCardProps { + instance: ProblemInstance; + template: ProblemTemplate; + /** Reports the FIRST check of this problem: (correct, timeSpentSeconds) */ + onAnswered: (correct: boolean, timeSpentSeconds: number) => void; +} + +function DrillProblemCard({ instance, template, onAnswered }: DrillProblemCardProps) { + const t = useTranslations('practice'); + const [input, setInput] = useState(''); + const [feedback, setFeedback] = useState<{ correct: boolean; mistake: Mistake | null } | null>( + null, + ); + const [hintsShown, setHintsShown] = useState(0); + const [elapsed, setElapsed] = useState(0); + const startRef = useRef(Date.now()); + const recordedRef = useRef(false); + + // Tick the per-problem timer; freeze once the first answer is checked. + const onTick = useEffectEvent(() => { + if (!recordedRef.current) { + setElapsed(Math.round((Date.now() - startRef.current) / 1000)); + } + }); + useEffect(() => { + const id = setInterval(() => { + onTick(); + }, 1000); + return () => clearInterval(id); + }, []); + + const handleCheck = () => { + const trimmed = input.trim(); + if (trimmed.length === 0) return; + + const correct = instance.graded + ? checkGradedAnswer(trimmed, instance.graded).correct + : trimmed.toLowerCase() === instance.solution.answer.trim().toLowerCase(); + + const mistake = correct + ? null + : (template.commonMistakes + .map((detector) => detector(instance.parameters, trimmed)) + .find((found): found is Mistake => found !== null) ?? null); + + setFeedback({ correct, mistake }); + + if (!recordedRef.current) { + recordedRef.current = true; + onAnswered(correct, Math.max(1, Math.round((Date.now() - startRef.current) / 1000))); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleCheck(); + } + }; + + return ( + + +
+ + + + +
+
+ + {/* Statement */} +
+ +
+ + {/* Answer input */} +
+ +
+ { + setInput(event.target.value); + setFeedback(null); + }} + onKeyDown={handleKeyDown} + placeholder={t('drill.answerPlaceholder')} + className="font-mono focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + spellCheck={false} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + /> + +
+
+ + {/* Feedback */} + {feedback && ( +
+ {feedback.correct ? ( +
+ )} + + {/* Progressive hints */} +
+
+ + {hintsShown > 0 && ( + + {t('drill.hintCost', { count: hintsShown })} + + )} +
+ {hintsShown > 0 && ( +
    + {instance.hints.slice(0, hintsShown).map((hint, index) => ( +
  1. + {index + 1}. + +
  2. + ))} +
+ )} +
+
+
+ ); +} + +// ─── Drill shell (tally, session persistence, URL state) ──────────────────── + +/** + * DrillMode — infinite randomized practice. + * + * Problems are generated deterministically from (templateId, seed); the + * seed lives in the URL so every problem is shareable and reproducible. + * Grading uses the CAS-backed graded answer (equivalent forms accepted). + * Session aggregates persist via startPracticeSession / + * completePracticeSession — per-attempt rows are intentionally NOT + * written for generated problems (Attempt.problemId is a required FK to + * the static Problem table). + */ +export function DrillMode({ templateId, seed, onSeedChange, onExit }: DrillModeProps) { + const t = useTranslations('practice'); + const prefersReduced = useReducedMotion() ?? false; + + const [tally, setTally] = useState({ + attempted: 0, + correct: 0, + streak: 0, + bestStreak: 0, + }); + const [copied, setCopied] = useState(false); + // Plain state (not a ref) — a ref write never triggers a re-render, so + // reading a ref directly in the "End drill & save" button's `disabled` + // expression would leave it stuck disabled after the session resolves. + const [sessionId, setSessionId] = useState(null); + const [savedThisSegment, setSavedThisSegment] = useState(false); + const totalTimeRef = useRef(0); + const sessionRequestedRef = useRef(false); + // Topic the *current* tally segment belongs to. Captured once, when the + // segment's session is requested — never re-derived from the live + // `template` prop, so switching the template mid-drill can't relabel + // problems already answered under a different topic. + const segmentTopicRef = useRef(null); + + const [startState, startAction] = useActionState, FormData>( + startPracticeSession, + { success: false }, + ); + const [completeState, completeAction] = useActionState< + ActionResult, + FormData + >(completePracticeSession, { success: false }); + + useEffect(() => { + if (startState.success && startState.data?.sessionId) { + setSessionId(startState.data.sessionId); + setSavedThisSegment(false); + } + }, [startState]); + + useEffect(() => { + if (completeState.success) { + setSavedThisSegment(true); + } + }, [completeState]); + + const template = templateEngine.getTemplate(templateId); + let instance: ProblemInstance | null = null; + if (template) { + try { + instance = templateEngine.generate(templateId, seed); + } catch { + instance = null; + } + } + + /** Reset all per-segment tracking so the next answer starts a fresh session. */ + const resetSegment = () => { + setTally({ attempted: 0, correct: 0, streak: 0, bestStreak: 0 }); + setSessionId(null); + setSavedThisSegment(false); + sessionRequestedRef.current = false; + segmentTopicRef.current = null; + totalTimeRef.current = 0; + }; + + const handleAnswered = (correct: boolean, timeSpentSeconds: number) => { + totalTimeRef.current += timeSpentSeconds; + setTally((prev) => { + const streak = correct ? prev.streak + 1 : 0; + return { + attempted: prev.attempted + 1, + correct: prev.correct + (correct ? 1 : 0), + streak, + bestStreak: Math.max(prev.bestStreak, streak), + }; + }); + + // Create the practice session on the first checked answer + if (!sessionRequestedRef.current && template) { + sessionRequestedRef.current = true; + segmentTopicRef.current = template.category; + const fd = new FormData(); + fd.set('topic', template.category); + fd.set('questionCount', '1'); + fd.set('adaptive', 'false'); + startAction(fd); + } + }; + + const handleGenerateAnother = () => { + onSeedChange({ templateId, seed: randomSeedString() }); + }; + + const handleTemplateChange = (nextTemplateId: string) => { + // Switching templates changes the topic — attribute everything + // answered so far to the topic the segment actually started under, + // then start a clean segment for the new topic. Without this, all + // correct answers (across every topic practiced) would be attributed + // to whichever template happens to be selected when the drill ends. + if (sessionId && segmentTopicRef.current && tally.attempted > 0) { + completeAction( + buildCompleteFormData(sessionId, tally, totalTimeRef.current, segmentTopicRef.current), + ); + } + resetSegment(); + onSeedChange({ templateId: nextTemplateId, seed: randomSeedString() }); + }; + + const handleCopyLink = () => { + navigator.clipboard + .writeText(window.location.href) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }) + .catch(() => { + // Clipboard permission denied — silent fail + }); + }; + + const handleEndDrill = () => { + if (!sessionId || !segmentTopicRef.current || tally.attempted === 0) return; + completeAction( + buildCompleteFormData(sessionId, tally, totalTimeRef.current, segmentTopicRef.current), + ); + }; + + const sessionErrored = + (sessionRequestedRef.current && !startState.success && Boolean(startState.error)) || + (!completeState.success && Boolean(completeState.error)); + + return ( + + {/* Header */} +
+

+ + {t('drill.title')} + +

+

{t('drill.description')}

+
+ + {/* Template + seed controls */} + + +
+
+ + +
+
+ +
+ + {seed} + + {template && ( + + {'★'.repeat(template.difficulty)} + + )} +
+
+
+ + {/* Tally chips */} +
+ + + 0 ? 'text-orange-500' : 'text-muted-foreground', + )} + aria-hidden="true" + /> + } + > + {t('drill.streak', { count: tally.streak })} + +
+
+
+ + {/* Problem (remounts per template+seed, resetting per-problem state) */} + {instance && template ? ( + + ) : ( + + + + + )} + + {/* Actions */} +
+ + + + +
+ + {/* Session persistence status */} + {savedThisSegment && ( +

+

+ )} + {!savedThisSegment && sessionErrored && ( +

+

+ )} +
+ ); +} diff --git a/apps/web/components/math/interactive-solver.tsx b/apps/web/components/math/interactive-solver.tsx index 84432e45..e73cc9ec 100644 --- a/apps/web/components/math/interactive-solver.tsx +++ b/apps/web/components/math/interactive-solver.tsx @@ -1,5 +1,6 @@ 'use client'; +import { isValidExpression } from '@nextcalc/math-engine/parser'; import type { Hint, Problem, SolutionStep } from '@nextcalc/math-engine/problems'; import { ArrowRight, @@ -18,6 +19,7 @@ import { } from 'lucide-react'; import { AnimatePresence, m } from 'motion/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { CheckWorkPanel } from '@/components/math/check-work-panel'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -119,6 +121,14 @@ export function InteractiveSolver({ const potentialPoints = Math.max(0, problem.points - hintPenalty); + // "Check my work" canonical: the stored answer with a leading "x =" + // stripped — only shown when it parses as a math expression (prose + // answers would make the checker meaningless). + const checkWorkCanonical = String(problem.solution.answer) + .replace(/^[a-zA-Z]\s*=\s*/, '') + .trim(); + const showCheckWork = checkWorkCanonical.length > 0 && isValidExpression(checkWorkCanonical); + // Format time display const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); @@ -288,6 +298,13 @@ export function InteractiveSolver({ )} + {/* Self-verification aid — separate from the graded submit */} + {showCheckWork && ( +
+ +
+ )} +