Skip to content

Commit b20b4a7

Browse files
Edwin ChanEdwin Chan
authored andcommitted
Add practice answer preview and latex grading
1 parent 8f8cebf commit b20b4a7

11 files changed

Lines changed: 258 additions & 14 deletions

File tree

CENTRAL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Stack: Next.js, NextAuth (Google + dev bypass), Prisma, KaTeX for math, Lucide i
3535

3636
- **Mobile/classes cleanup** — fixed mobile `/problem-sets` hiding task rows by restoring the responsive card table, stabilized the mobile sidebar grid to avoid icon reflow on tap/focus, and made dashboard auth/actions render as a compact mobile account card. `/classes` now has a teacher/admin `Announcements` subtab that lists existing class messages and supports author/admin deletion. Display-name fallbacks now treat literal `"null"`/`"undefined"` strings as empty via `lib/display-name.ts`.
3737
- **Mobile sidebar focus fix** — mobile sidebar sheet geometry now remains stable under `:hover`, `:focus`, and `:focus-within`, preventing the nav grid from jumping upward when a mobile tap focuses one of the upper links (source: `app/globals.css`).
38+
- **Practice answer preview and LaTeX grading** — Practice now renders a small KaTeX preview above the answer input using `mathInputToTex(...)`. `lib/grading.ts` normalizes math delimiters and common LaTeX forms through `lib/math-input.ts`, so expression answers and answer keys can use `$...$`, `\sqrt{...}`, `\frac{...}{...}`, braced powers, and `\pi`.
3839
- **Anime.js animation pass** — the shared `MathCurveLoader` now drives SVG path drawing, rotation, and dot pulses with Anime.js v4 instead of CSS keyframes, `AnimeRouteEffects` adds reduced-motion-aware route reveal animations for key panels/rows/actions, and analytics surfaces use `AnalyticsMotion` plus animated `TrendChart` path/dots for growing bars and drawn charts. `TypewriterGreeting` keeps the existing typed/deleted text state machine and only animates the caret, avoiding per-character text tween flicker. Dependency: `animejs` in `package.json`.
3940
- **Per-set analytics access** — admins can open `/admin/sets/[id]/analytics` directly from the student-facing problem set header and the admin set editor; the editor delete action uses full-size topbar button sizing while the list keeps compact row actions.
4041
- **Problem-set writeups**`/problem-sets/[slug]/writeups` lets signed-in users post LaTeX/HTML solution notes with up to four images, then sort by latest/top and upvote/downvote posts. Authors can delete their own writeups with confirmation; admins can delete any writeup. `/writeups` is the sidebar directory for latest/top writeups and problem-set search. Persistence uses new `Writeup`, `WriteupImage`, and `WriteupVote` Prisma models plus `lib/writeup-images.ts`; image bytes are stored through the existing storage layer and streamed via `/api/files/[id]`.

DBSMO/Projects/dbsmo/Components.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ This note maps important [[dbsmo]] UI/components to their source files and usage
4242

4343
## Practice and Games
4444

45-
- `PracticePage` in `app/practice/page.tsx`: client practice workflow using `/api/practice/tags`, `/api/practice/next`, and `/api/practice/submit`; renders `LatexStatement`.
45+
- `PracticePage` in `app/practice/page.tsx`: client practice workflow using `/api/practice/tags`, `/api/practice/next`, and `/api/practice/submit`; renders `LatexStatement` and shows a live KaTeX answer preview above the answer input using `mathInputToTex(...)`.
4646
- `FtwLobbyForm` in `app/ftw/lobby-form.tsx`: FTW mode entry. It filters tags, creates solo matches through `/api/ftw/matches`, creates rooms through `/api/ftw/rooms`, and joins rooms through `/api/ftw/rooms/[code]/join`.
4747
- `FtwMatchClient` in `app/ftw/match/[id]/match-client.tsx`: solo FTW problem/submit client.
4848
- `FtwRoomClient` in `app/ftw/room/[code]/room-client.tsx`: multiplayer room UI with polling state, host actions, answer submission, locked/revealed answers, scores, and current problem rendering through `LatexStatement`.

DBSMO/Projects/dbsmo/Data and Storage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Sources: `app/api/submit/route.ts`, `lib/grading.ts`, `prisma/schema.prisma`.
5858

5959
## Grading Data
6060

61-
`AnswerType` exists as a Prisma enum with uppercase values (`EXACT`, `INTEGER`, `DECIMAL`, `FRACTION`, `SET`, `MULTIPLE`, `EXPRESSION`), while `lib/grading.ts` uses lowercase string types and callers map/lowercase Prisma values before grading (sources: `prisma/schema.prisma`, `lib/grading.ts`, `app/api/submit/route.ts`, `app/api/practice/submit/route.ts`).
61+
`AnswerType` exists as a Prisma enum with uppercase values (`EXACT`, `INTEGER`, `DECIMAL`, `FRACTION`, `SET`, `MULTIPLE`, `EXPRESSION`), while `lib/grading.ts` uses lowercase string types and callers map/lowercase Prisma values before grading. `lib/math-input.ts` strips math delimiters and converts common LaTeX forms like `$5$`, `\sqrt{5}`, `\frac{1}{2}`, braced powers, and `\pi` before expression evaluation (sources: `prisma/schema.prisma`, `lib/grading.ts`, `lib/math-input.ts`, `app/api/submit/route.ts`, `app/api/practice/submit/route.ts`).
6262

6363
Graded responses store both raw and normalized answers in `Response`, which supports later review/export/regrade (sources: `prisma/schema.prisma`, `app/api/submit/route.ts`). Admin regrading is exposed at `app/api/admin/sets/[id]/regrade/route.ts`.
6464

DBSMO/Projects/dbsmo/File Map.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ This is a folder/file guide for [[dbsmo]], verified with CodeGraph and `rg --fil
5959
- `lib/db.ts` - shared Prisma Client with Prisma PG adapter (source: `lib/db.ts`).
6060
- `lib/auth.ts`, `lib/auth-server.ts` - NextAuth configuration and server helpers (sources: named files).
6161
- `lib/permissions.ts` - role-to-permission mapping and `hasPermission` helper (source: `lib/permissions.ts`).
62-
- `lib/grading.ts` - deterministic answer normalization and grading engine (source: `lib/grading.ts`).
62+
- `lib/grading.ts`, `lib/math-input.ts` - deterministic answer normalization and grading engine plus math-input helpers for stripping delimiters, converting common LaTeX forms, and rendering practice answer previews (sources: named files).
6363
- `lib/visibility.ts` - student visibility/status helpers for problem sets (source: `lib/visibility.ts`).
6464
- `lib/problem-tags.ts`, `lib/problem-content-format.ts`, `lib/problem-set-order.ts`, `lib/problem-set-authoring.ts` - problem metadata normalization, ordering, and authoring validation (sources: named files).
6565
- `lib/import/` - JSON/ZIP import validation, manifest and answer schemas, image asset handling, optional image ZIP parsing, ZIP path safety, JSON draft storage, image asset persistence, and JSON export conversion (sources: `lib/import/**`).

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,6 @@ Session updates from the CodeGraph/Second Brain indexing pass onward:
4747
- Fixed mobile problem-set task cards/sidebar tap layout/dashboard account controls, added the `/classes` Announcements subtab with existing-message deletion, and normalized literal `null` display names.
4848
- Added direct per-set Analytics buttons on both the student-facing set header for admins and the admin set editor, and made the set-editor Delete button match the other topbar action sizing.
4949
- Fixed the mobile sidebar tap/focus jump by keeping the opened sheet geometry stable when nav items receive focus.
50+
- Added a live rendered answer preview in Practice and expanded expression grading to understand common LaTeX input/keys such as `$5$`, `\sqrt{5}`, `\frac{1}{2}`, and `2^{1/2}`.
5051

5152
© 2026 Cosmic Crusader

app/globals.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8966,6 +8966,33 @@ html:not(.light) .ftw-rank.rank-1 {
89668966
display: grid;
89678967
gap: 10px;
89688968
}
8969+
8970+
.practice-answer-preview {
8971+
display: inline-flex;
8972+
align-items: center;
8973+
justify-content: center;
8974+
min-width: 86px;
8975+
min-height: 62px;
8976+
max-width: min(100%, 360px);
8977+
margin: 0 0 -10px;
8978+
padding: 16px 22px;
8979+
border: 1px solid var(--color-border);
8980+
border-bottom: 0;
8981+
border-radius: 12px 12px 0 0;
8982+
background: color-mix(in srgb, var(--meter-track) 82%, var(--color-card-bg));
8983+
color: var(--color-text-strong);
8984+
font-size: 1.1rem;
8985+
overflow-x: auto;
8986+
}
8987+
8988+
.practice-answer-preview .katex-display {
8989+
margin: 0;
8990+
}
8991+
8992+
.practice-answer-preview .katex {
8993+
font-size: 1.18em;
8994+
}
8995+
89698996
.practice-answer-input {
89708997
width: 100%;
89718998
min-height: 46px;

app/practice/page.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { MathCurveLoader } from "@/app/math-curve-loader";
1818
import { SearchSuggestInput } from "@/app/search-suggest-input";
1919
import { LatexStatement } from "../problem-sets/[slug]/latex-statement";
20+
import { mathInputToTex } from "@/lib/math-input";
2021

2122
type Problem = {
2223
id: string;
@@ -159,6 +160,7 @@ export default function PracticePage() {
159160
if (!q) return tags;
160161
return tags.filter((t) => t.toLowerCase().includes(q));
161162
}, [tagQuery, tags]);
163+
const answerPreviewTex = useMemo(() => mathInputToTex(answer), [answer]);
162164

163165
return (
164166
<main className="single-page">
@@ -318,6 +320,11 @@ export default function PracticePage() {
318320
</div>
319321

320322
<form onSubmit={submitAnswer} className="practice-answer-form">
323+
{answerPreviewTex ? (
324+
<div className="practice-answer-preview" aria-live="polite">
325+
<LatexStatement statement={`$${answerPreviewTex}$`} />
326+
</div>
327+
) : null}
321328
<input
322329
type="text"
323330
className="practice-answer-input"

lib/grading.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { normalizeMathInputForEvaluation, stripMathDelimiters } from "@/lib/math-input";
2+
13
export type AnswerType =
24
| "exact"
35
| "integer"
@@ -101,7 +103,7 @@ export function normalizeAnswer(
101103
}
102104

103105
function normalizeText(value: string, caseSensitive: boolean): string {
104-
const normalized = value
106+
const normalized = stripMathDelimiters(value)
105107
.trim()
106108
.replace(/[]/g, (match) => (match === "," ? "," : ";"))
107109
.replace(/\s+/g, " ");
@@ -126,7 +128,12 @@ function normalizeDecimal(value: string): string {
126128
}
127129

128130
function normalizeFraction(value: string): string {
129-
const [numeratorRaw, denominatorRaw] = value.split("/");
131+
const stripped = stripMathDelimiters(value);
132+
const latexFractionMatch = stripped.match(/^\\frac\{(-?\d+)\}\{(-?\d+)\}$/);
133+
const fractionValue = latexFractionMatch
134+
? `${latexFractionMatch[1]}/${latexFractionMatch[2]}`
135+
: value;
136+
const [numeratorRaw, denominatorRaw] = fractionValue.split("/");
130137
if (!numeratorRaw || !denominatorRaw) {
131138
return normalizeDecimal(value);
132139
}
@@ -200,15 +207,7 @@ const MATH_CONSTANTS: Record<string, number> = {
200207
};
201208

202209
function evaluateMathExpression(rawExpression: string): number {
203-
const normalized = rawExpression
204-
.trim()
205-
.toLowerCase()
206-
.replace(/[]/g, ",")
207-
.replace(/[]/g, "-")
208-
.replace(/[×·]/g, "*")
209-
.replace(/[÷]/g, "/")
210-
.replace(/π/g, "pi")
211-
.replace(/\*\*/g, "^");
210+
const normalized = normalizeMathInputForEvaluation(rawExpression);
212211

213212
if (!normalized || normalized.length > 200) {
214213
return Number.NaN;

lib/math-input.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
export function stripMathDelimiters(raw: string): string {
2+
let value = raw.trim();
3+
let changed = true;
4+
5+
while (changed) {
6+
changed = false;
7+
const pairs: Array<[string, string]> = [
8+
["$$", "$$"],
9+
["\\[", "\\]"],
10+
["\\(", "\\)"],
11+
["$", "$"],
12+
];
13+
14+
for (const [open, close] of pairs) {
15+
if (
16+
value.startsWith(open) &&
17+
value.endsWith(close) &&
18+
value.length >= open.length + close.length
19+
) {
20+
value = value.slice(open.length, value.length - close.length).trim();
21+
changed = true;
22+
}
23+
}
24+
}
25+
26+
return value;
27+
}
28+
29+
function replaceLatexCommandWithGroup(
30+
value: string,
31+
command: string,
32+
replacement: (inner: string) => string,
33+
): string {
34+
let result = value;
35+
let index = result.indexOf(command);
36+
37+
while (index !== -1) {
38+
const openIndex = result.indexOf("{", index + command.length);
39+
if (openIndex === -1) break;
40+
41+
let depth = 0;
42+
let closeIndex = -1;
43+
for (let cursor = openIndex; cursor < result.length; cursor += 1) {
44+
if (result[cursor] === "{") depth += 1;
45+
if (result[cursor] === "}") {
46+
depth -= 1;
47+
if (depth === 0) {
48+
closeIndex = cursor;
49+
break;
50+
}
51+
}
52+
}
53+
54+
if (closeIndex === -1) break;
55+
56+
const inner = result.slice(openIndex + 1, closeIndex);
57+
result = `${result.slice(0, index)}${replacement(inner)}${result.slice(closeIndex + 1)}`;
58+
index = result.indexOf(command, index + 1);
59+
}
60+
61+
return result;
62+
}
63+
64+
function replaceLatexFractions(value: string): string {
65+
let result = value;
66+
let index = result.indexOf("\\frac");
67+
68+
while (index !== -1) {
69+
const numeratorOpen = result.indexOf("{", index + "\\frac".length);
70+
if (numeratorOpen === -1) break;
71+
72+
const numeratorClose = findMatchingBrace(result, numeratorOpen);
73+
if (numeratorClose === -1) break;
74+
75+
const denominatorOpen = result.indexOf("{", numeratorClose + 1);
76+
if (denominatorOpen === -1) break;
77+
78+
const denominatorClose = findMatchingBrace(result, denominatorOpen);
79+
if (denominatorClose === -1) break;
80+
81+
const numerator = result.slice(numeratorOpen + 1, numeratorClose);
82+
const denominator = result.slice(denominatorOpen + 1, denominatorClose);
83+
result = `${result.slice(0, index)}((${numerator})/(${denominator}))${result.slice(denominatorClose + 1)}`;
84+
index = result.indexOf("\\frac", index + 1);
85+
}
86+
87+
return result;
88+
}
89+
90+
function findMatchingBrace(value: string, openIndex: number): number {
91+
let depth = 0;
92+
for (let index = openIndex; index < value.length; index += 1) {
93+
if (value[index] === "{") depth += 1;
94+
if (value[index] === "}") {
95+
depth -= 1;
96+
if (depth === 0) return index;
97+
}
98+
}
99+
return -1;
100+
}
101+
102+
export function normalizeMathInputForEvaluation(raw: string): string {
103+
let value = stripMathDelimiters(raw)
104+
.trim()
105+
.toLowerCase()
106+
.replace(/\\left/g, "")
107+
.replace(/\\right/g, "")
108+
.replace(/\\,/g, "")
109+
.replace(/[]/g, ",")
110+
.replace(/[]/g, "-")
111+
.replace(/[×·]/g, "*")
112+
.replace(/[÷]/g, "/")
113+
.replace(/\\cdot|\\times/g, "*")
114+
.replace(/\\div/g, "/")
115+
.replace(/\\pi|π/g, "pi")
116+
.replace(/\*\*/g, "^");
117+
118+
value = replaceLatexFractions(value);
119+
value = replaceLatexCommandWithGroup(value, "\\sqrt", (inner) => `sqrt(${inner})`);
120+
value = value.replace(/sqrt\s*\{([^{}]+)\}/g, "sqrt($1)");
121+
value = value.replace(/\^\s*\{([^{}]+)\}/g, "^($1)");
122+
value = value.replace(/[{}]/g, "");
123+
124+
return value;
125+
}
126+
127+
export function mathInputToTex(raw: string): string {
128+
let value = stripMathDelimiters(raw).trim();
129+
130+
if (!value) {
131+
return "";
132+
}
133+
134+
value = value
135+
.replace(/\\left/g, "")
136+
.replace(/\\right/g, "")
137+
.replace(/π/g, "\\pi")
138+
.replace(/\bpi\b/gi, "\\pi")
139+
.replace(/\*/g, "\\cdot ");
140+
141+
value = value.replace(/(^|[^\\])\bsqrt\s*\(([^()]+)\)/gi, "$1\\sqrt{$2}");
142+
value = value.replace(/(^|[^\\])\bsqrt\s*\{([^{}]+)\}/gi, "$1\\sqrt{$2}");
143+
value = value.replace(/\^\s*\(([^()]+)\)/g, "^{$1}");
144+
value = value.replace(/\^\s*([^\s+\-*/^]+)/g, "^{$1}");
145+
146+
return value;
147+
}

tests/grading.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ describe("normalizeAnswer", () => {
99

1010
it("normalizes equivalent fractions", () => {
1111
expect(normalizeAnswer(" 3/6 ", "fraction")).toBe("1/2");
12+
expect(normalizeAnswer("\\frac{3}{6}", "fraction")).toBe("1/2");
1213
});
1314

1415
it("normalizes unordered sets", () => {
@@ -50,6 +51,45 @@ describe("gradeAnswer", () => {
5051
expect(result.normalizedAnswer).toBe("1.4142135623731");
5152
});
5253

54+
it("accepts math-delimited integer answers", () => {
55+
const result = gradeAnswer({
56+
answerType: "integer",
57+
answerKey: "5",
58+
rawAnswer: "$5$",
59+
});
60+
61+
expect(result.isCorrect).toBe(true);
62+
expect(result.normalizedAnswer).toBe("5");
63+
});
64+
65+
it("evaluates LaTeX expression answer keys and student answers", () => {
66+
const result = gradeAnswer({
67+
answerType: "expression",
68+
answerKey: "\\sqrt{5}",
69+
rawAnswer: "sqrt(5)",
70+
});
71+
72+
expect(result.isCorrect).toBe(true);
73+
});
74+
75+
it("evaluates LaTeX fractions and braced powers in expressions", () => {
76+
expect(
77+
gradeAnswer({
78+
answerType: "expression",
79+
answerKey: "\\frac{1}{2}",
80+
rawAnswer: "0.5",
81+
}).isCorrect,
82+
).toBe(true);
83+
84+
expect(
85+
gradeAnswer({
86+
answerType: "expression",
87+
answerKey: "2^{1/2}",
88+
rawAnswer: "\\sqrt{2}",
89+
}).isCorrect,
90+
).toBe(true);
91+
});
92+
5393
it("supports constants, fractions, and implicit multiplication in expressions", () => {
5494
const result = gradeAnswer({
5595
answerType: "expression",

0 commit comments

Comments
 (0)