Skip to content
Merged
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
134 changes: 132 additions & 2 deletions apps/web/app/[locale]/practice/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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<ReadonlyArray<Problem>>([]);
const [config, setConfig] = useState<PracticeConfig>({
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -410,6 +457,25 @@ export default function PracticePage() {
[completeSessionAction],
);

// ── Infinite drill mode ─────────────────────────────────────────────────
if (drillActive) {
return (
<div className="relative min-h-screen">
<AnimatedBackground prefersReduced={prefersReduced} />
<div className="container mx-auto py-8 px-4">
{drillSeed && (
<DrillMode
templateId={drillTemplate}
seed={drillSeed}
onSeedChange={handleDrillChange}
onExit={handleExitDrill}
/>
)}
</div>
</div>
);
}

// ── Empty state (no matching problems) ─────────────────────────────────────
if (!isConfiguring) {
if (problems.length === 0) {
Expand Down Expand Up @@ -520,6 +586,58 @@ export default function PracticePage() {

{/* Configuration Form */}
<div className="max-w-3xl mx-auto">
{/* Infinite Drill entry */}
<m.div
className="mb-6"
{...(prefersReduced
? {}
: {
initial: 'hidden',
whileInView: 'visible',
viewport: { once: true },
variants: cardVariants,
})}
>
<Card
className="backdrop-blur-md bg-card/50 border-border"
style={{
boxShadow:
'0 0 0 1px oklch(0.65 0.22 264 / 0.10) inset, 0 8px 32px oklch(0.65 0.22 264 / 0.08)',
}}
>
<CardContent className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 py-5">
<div className="flex items-center gap-3">
<span
className="inline-flex items-center justify-center size-9 rounded-xl flex-shrink-0"
style={{
background: 'oklch(0.65 0.22 264 / 0.12)',
border: '1px solid oklch(0.65 0.22 264 / 0.25)',
}}
aria-hidden="true"
>
<Zap className="size-5" style={{ color: 'oklch(0.65 0.22 264)' }} />
</span>
<div>
<div className="font-semibold text-foreground">{t('drill.title')}</div>
<div className="text-sm text-muted-foreground">{t('drill.description')}</div>
</div>
</div>
<Button
onClick={handleEnterDrill}
className="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
style={{
background:
'linear-gradient(135deg, oklch(0.60 0.22 264), oklch(0.55 0.20 290))',
boxShadow: '0 4px 20px oklch(0.60 0.22 264 / 0.30)',
}}
>
<Zap className="size-4 mr-2" aria-hidden="true" />
{t('drill.enter')}
</Button>
</CardContent>
</Card>
</m.div>

<m.div
{...(prefersReduced
? {}
Expand Down Expand Up @@ -800,3 +918,15 @@ export default function PracticePage() {
</div>
);
}

/**
* Page shell: useSearchParams (drill deep-links) requires a Suspense
* boundary for static prerendering.
*/
export default function PracticePage() {
return (
<Suspense fallback={null}>
<PracticePageInner />
</Suspense>
);
}
31 changes: 31 additions & 0 deletions apps/web/app/actions/practice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
55 changes: 51 additions & 4 deletions apps/web/app/actions/problems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -72,19 +112,26 @@ export async function submitAnswer(
where: { isHidden: false },
orderBy: { order: 'asc' },
},
topics: true,
topics: { include: { topic: { select: { slug: true } } } },
},
});

if (!problem) {
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;

Expand Down
Loading