From 2e1a411fa99e11c1f41f33a4a7352057d0a02baf Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 20:38:46 -0700
Subject: [PATCH 01/15] Default the backend to PostgreSQL
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
backend/.env.docker | 7 ++++++-
backend/Dockerfile | 2 +-
backend/cheat_sheet/settings.py | 23 +++++++++++++++--------
docker-compose.yml | 20 +++++++++++---------
4 files changed, 33 insertions(+), 19 deletions(-)
diff --git a/backend/.env.docker b/backend/.env.docker
index 9c87c95..276a6da 100644
--- a/backend/.env.docker
+++ b/backend/.env.docker
@@ -5,4 +5,9 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,backend
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
# PostgreSQL connection (matches the db service in docker-compose.yml)
-DATABASE_URL=postgres://cheatsheet_user:cheatsheet_pass@db:5432/cheatsheet_db
+DATABASE_URL=postgresql://cheatsheet_user:cheatsheet_pass@db:5432/cheatsheet_db
+POSTGRES_HOST=db
+POSTGRES_PORT=5432
+POSTGRES_DB=cheatsheet_db
+POSTGRES_USER=cheatsheet_user
+POSTGRES_PASSWORD=cheatsheet_pass
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 603f00f..d371cc6 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -35,4 +35,4 @@ COPY . .
EXPOSE 8000
-CMD ["sh", "-c", "until pg_isready -h db -U cheatsheet_user; do sleep 2; done && python manage.py migrate && python manage.py runserver 0.0.0.0:8000"]
+CMD ["sh", "-c", "export PGPASSWORD=${POSTGRES_PASSWORD:-cheatsheet_pass}; until pg_isready -h ${POSTGRES_HOST:-db} -p ${POSTGRES_PORT:-5432} -U ${POSTGRES_USER:-cheatsheet_user} -d ${POSTGRES_DB:-cheatsheet_db}; do sleep 2; done && python manage.py migrate && python manage.py runserver 0.0.0.0:8000"]
diff --git a/backend/cheat_sheet/settings.py b/backend/cheat_sheet/settings.py
index 2e90c30..1471d9e 100644
--- a/backend/cheat_sheet/settings.py
+++ b/backend/cheat_sheet/settings.py
@@ -1,16 +1,23 @@
-"""
-Django settings for cheat_sheet project.
-"""
+"""Django settings for cheat_sheet project."""
-from pathlib import Path
import os
-from dotenv import load_dotenv
-from django.core.exceptions import ImproperlyConfigured
+from pathlib import Path
+
import dj_database_url
+from django.core.exceptions import ImproperlyConfigured
+from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
+DEFAULT_DATABASE_URL = (
+ f"postgresql://{os.getenv('POSTGRES_USER', 'cheatsheet_user')}:"
+ f"{os.getenv('POSTGRES_PASSWORD', 'cheatsheet_pass')}@"
+ f"{os.getenv('POSTGRES_HOST', 'localhost')}:"
+ f"{os.getenv('POSTGRES_PORT', '5432')}/"
+ f"{os.getenv('POSTGRES_DB', 'cheatsheet_db')}"
+)
+
DEBUG = os.getenv("DJANGO_DEBUG", "True") == "True"
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY")
@@ -80,10 +87,10 @@
WSGI_APPLICATION = "cheat_sheet.wsgi.application"
-# Database — uses DATABASE_URL env var, falls back to SQLite for local dev
+# Database — uses DATABASE_URL when provided and defaults to local PostgreSQL
DATABASES = {
"default": dj_database_url.config(
- default="sqlite:///" + str(BASE_DIR / "db.sqlite3"),
+ default=DEFAULT_DATABASE_URL,
conn_max_age=600,
)
}
diff --git a/docker-compose.yml b/docker-compose.yml
index f6725ff..7c8482d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,26 +2,28 @@ services:
db:
image: postgres:15
restart: unless-stopped
- environment:
- POSTGRES_DB: cheatsheet_db
- POSTGRES_USER: cheatsheet_user
- POSTGRES_PASSWORD: cheatsheet_pass
- # ports: comment out for now we'e not using atm
- # - "5432:5432"
+ env_file:
+ - ./backend/.env.docker
+ ports:
+ - "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
backend:
build: ./backend
ports:
- "8000:8000"
env_file:
- ./backend/.env.docker
- environment:
- - DATABASE_URL=postgres://cheatsheet_user:cheatsheet_pass@db:5432/cheatsheet_db
volumes:
- ./backend:/app
depends_on:
- - db
+ db:
+ condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health/"]
interval: 10s
From 9761f5b2b6f82152a66c1de72b3f37e3514dd144 Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 20:38:46 -0700
Subject: [PATCH 02/15] Add a PostgreSQL env template
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
.gitignore | 2 +-
backend/.env.example | 10 ++++++++++
2 files changed, 11 insertions(+), 1 deletion(-)
create mode 100644 backend/.env.example
diff --git a/.gitignore b/.gitignore
index 122ac54..79405ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,7 @@ session-*.md
.env.*
frontend/.env
backend/.env
+!backend/.env.example
# Ignore all SQLite DBs, not just the default
*.sqlite3
@@ -65,4 +66,3 @@ backend/coverage/
# OpenCode/Sisyphus
.sisyphus/
-
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..fef0bd3
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,10 @@
+DJANGO_DEBUG=True
+DJANGO_SECRET_KEY=django-insecure-dev-secret-key-change-me
+DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
+CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
+POSTGRES_HOST=localhost
+POSTGRES_PORT=5432
+POSTGRES_DB=cheatsheet_db
+POSTGRES_USER=cheatsheet_user
+POSTGRES_PASSWORD=cheatsheet_pass
+DATABASE_URL=postgresql://cheatsheet_user:cheatsheet_pass@localhost:5432/cheatsheet_db
From 5bbf06affcc86f73803aab682d2703bbb8038e7f Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 20:38:46 -0700
Subject: [PATCH 03/15] Run backend CI against PostgreSQL
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
.github/workflows/ci.yml | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 85ee6cc..792c2e4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -9,6 +9,22 @@ on:
jobs:
backend:
runs-on: ubuntu-latest
+ env:
+ DATABASE_URL: postgresql://postgres:postgres@localhost:5432/cheatsheet_test
+ services:
+ postgres:
+ image: postgres:15
+ env:
+ POSTGRES_DB: cheatsheet_test
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd="pg_isready -U postgres -d cheatsheet_test"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
defaults:
run:
working-directory: ./backend
From c19957e56569e472c5576de9cb5b6f639deb738b Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 20:38:46 -0700
Subject: [PATCH 04/15] Document the PostgreSQL development workflow
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
README.md | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index d0d404e..8fe2674 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele
| Frontend | React 18 + Vite |
| Backend | Django 6 + Django REST Framework |
| LaTeX Engine | Tectonic |
-| Database | SQLite (dev) / PostgreSQL (Docker/prod) |
+| Database | PostgreSQL 15 |
| Container | Docker Compose |
## Project Structure
@@ -110,19 +110,30 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele
- Python 3.13+
- Node.js 20+
+- PostgreSQL 15+ (or Docker)
- Tectonic (for PDF compilation)
### Backend Setup
+Start PostgreSQL before running Django locally. The quickest option is:
+
+```bash
+docker compose up -d db
+```
+
```bash
cd backend
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install -r requirements.txt
+cp .env.example .env
python manage.py migrate
python manage.py runserver
```
+`backend/.env.example` is preconfigured for a PostgreSQL instance on `localhost:5432`.
+If you want to run Postgres in Docker while keeping the backend local, start it with `docker compose up -d db`.
+
The API will be available at `http://localhost:8000/api/`.
### Frontend Setup
@@ -142,12 +153,14 @@ docker compose up --build
```
This builds and starts the Django backend, React frontend, and PostgreSQL database.
+The database is also exposed on `localhost:5432` for local backend/test runs.
## Running Tests
### Backend (pytest)
```bash
+docker compose up -d db
cd backend
pytest # Run all tests
pytest -v # Run with verbose output
@@ -155,6 +168,8 @@ pytest -k "test_name" # Run tests matching pattern
pytest api/tests.py # Run specific test file
```
+Tests now run against PostgreSQL using the connection configured in `backend/.env`.
+
### Frontend (ESLint)
```bash
From 7a94955f2d5f15361e9630455d98fbd0ce265932 Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 20:54:51 -0700
Subject: [PATCH 05/15] Add a practice problem compiler MVP spec
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
docs/practice-problem-compiler-mvp.md | 370 ++++++++++++++++++++++++++
1 file changed, 370 insertions(+)
create mode 100644 docs/practice-problem-compiler-mvp.md
diff --git a/docs/practice-problem-compiler-mvp.md b/docs/practice-problem-compiler-mvp.md
new file mode 100644
index 0000000..4ddced5
--- /dev/null
+++ b/docs/practice-problem-compiler-mvp.md
@@ -0,0 +1,370 @@
+# Practice Problem Compiler MVP
+
+## Goal
+
+Let users author practice problems in a simple indented syntax, keep the original source, compile it into pure LaTeX, and reorder named problem blocks in the UI.
+
+## Product shape
+
+- One draggable **block** in the UI maps to one `PracticeProblem` record.
+- Each block has:
+ - a user-facing label/name
+ - source text in a simple syntax
+ - compiled LaTeX
+- The cheat sheet document still assembles a final PDF by stitching together stored LaTeX blocks.
+
+## MVP decisions
+
+- **Block unit:** reuse `PracticeProblem` as the block model.
+- **Compiler format name:** `simple_v1`
+- **Storage strategy:** compile on create/update and store both source and compiled LaTeX.
+- **UI strategy:** add a block list with drag reorder, label input, and source editor.
+- **Legacy compatibility:** existing `question_latex` / `answer_latex` rows still render until migrated.
+
+## Non-goals
+
+- Full math parser
+- Arbitrary raw LaTeX passthrough in `simple_v1`
+- Nested block trees
+- Substeps, hints, grading metadata, or answer checking
+- Multi-block source files inside a single textarea
+
+## User workflow
+
+1. User creates a practice-problem block.
+2. User enters a block label, such as `Quadratic factoring`.
+3. User writes source text in the simple syntax.
+4. Frontend saves the block through `/api/problems/`.
+5. Backend validates and compiles the block into LaTeX.
+6. Drag reorder changes block `order`.
+7. Cheat sheet PDF uses compiled block LaTeX in that saved order.
+
+## Data model changes
+
+Extend `backend/api/models.py` `PracticeProblem` with:
+
+- `label = models.CharField(max_length=120, blank=True, default="")`
+- `source_format = models.CharField(max_length=20, default="simple_v1")`
+- `source_text = models.TextField(blank=True, default="")`
+- `compiled_latex = models.TextField(blank=True, default="")`
+
+Keep existing fields during MVP rollout:
+
+- `question_latex`
+- `answer_latex`
+- `order`
+- `cheat_sheet`
+
+### Compatibility rule
+
+- New compiler-backed blocks use `label`, `source_format`, `source_text`, and `compiled_latex`.
+- Legacy rows without compiler data continue rendering from `question_latex` / `answer_latex`.
+
+## Compiler boundary
+
+Add a pure service module:
+
+- `backend/api/services/practice_problem_compiler.py`
+
+Recommended functions:
+
+```python
+def compile_source(source_text: str, label: str = "") -> CompilationResult:
+ ...
+
+def parse_source(source_text: str) -> ParsedProblemBlock:
+ ...
+
+def compile_math_expression(expression: str) -> str:
+ ...
+```
+
+`CompilationResult` should include:
+
+- `compiled_latex: str`
+- `errors: list[CompilerError]`
+
+`CompilerError` should include:
+
+- `line`
+- `column`
+- `message`
+
+### Why this boundary
+
+- Keeps parser/compiler logic out of models and views
+- Makes unit testing cheap
+- Lets serializers return line-aware validation errors
+- Keeps `CheatSheet.build_full_latex()` focused on document assembly only
+
+## Serializer behavior
+
+`PracticeProblemSerializer` should:
+
+- accept `label`, `source_format`, `source_text`, `order`, `cheat_sheet`
+- validate `source_text` when `source_format == "simple_v1"`
+- call the compiler service during create/update
+- store `compiled_latex`
+- expose read-only `compiled_latex`
+
+For MVP, no separate parse-preview endpoint is required.
+
+## Rendering behavior
+
+`CheatSheet._build_practice_problems_section()` should render blocks in this order:
+
+1. `compiled_latex` if present
+2. legacy `question_latex` / `answer_latex` fallback
+
+This keeps final document generation deterministic and avoids recompiling every block during PDF generation.
+
+## Syntax overview
+
+### Top-level structure
+
+`simple_v1` supports exactly two top-level keys:
+
+- `problem:`
+- `steps:`
+
+Indentation is significant.
+
+### Allowed child item types
+
+- `text:`
+- `math:`
+
+### Valid example
+
+```txt
+problem:
+ text: Solve for x
+ math: x^2 - 5x + 6 = 0
+
+steps:
+ text: Factor the trinomial
+ math: x^2 - 5x + 6 = (x - 2)(x - 3)
+ math: x - 2 = 0
+ math: x - 3 = 0
+ text: Therefore x = 2 or x = 3
+```
+
+Equivalent spaces-based indentation is also valid.
+
+### Invalid examples
+
+Unknown key:
+
+```txt
+problem:
+ text: Solve for x
+answer:
+ math: x = 2
+```
+
+Bad indentation:
+
+```txt
+problem:
+ text: Solve for x
+ math: x^2 - 5x + 6 = 0
+```
+
+Missing top-level section:
+
+```txt
+steps:
+ math: x = 2
+```
+
+## Syntax rules
+
+### `problem:`
+
+- required
+- must contain at least one child line
+- may contain one or more `text:` and `math:` lines
+- preserves author order
+
+### `steps:`
+
+- required for MVP
+- must contain at least one child line
+- each child becomes one displayed step
+- may contain `text:` and `math:` lines
+
+### Indentation rules
+
+- tabs or spaces are allowed
+- mixed indentation styles in the same block are invalid
+- child lines must be indented deeper than their parent key
+
+## Math compilation rules
+
+`math:` lines accept calculator-style keyboard input and apply a narrow rewrite set.
+
+### Supported rewrites in MVP
+
+- `^` for superscripts
+- `sqrt(x)` to `\sqrt{x}`
+- `<=` to `\le`
+- `>=` to `\ge`
+- `!=` to `\ne`
+- optional `*` to `\cdot`
+
+### Not supported in MVP
+
+- implicit multiplication normalization
+- pretty fraction inference from `/`
+- arbitrary function parsing
+- symbolic simplification
+- custom macros
+
+Unsupported constructs should return a readable validation error instead of guessing.
+
+## Example compiled output
+
+For label `Quadratic factoring`, this source:
+
+```txt
+problem:
+ text: Solve for x
+ math: x^2 - 5x + 6 = 0
+
+steps:
+ text: Factor the trinomial
+ math: x^2 - 5x + 6 = (x - 2)(x - 3)
+ math: x - 2 = 0
+ math: x - 3 = 0
+ text: Therefore x = 2 or x = 3
+```
+
+compiles to:
+
+```latex
+\subsection*{Quadratic factoring}
+\textbf{Problem.}
+
+Solve for \(x\)
+
+\[
+x^2 - 5x + 6 = 0
+\]
+
+\textbf{Steps.}
+\begin{enumerate}
+ \item Factor the trinomial
+ \item \(x^2 - 5x + 6 = (x - 2)(x - 3)\)
+ \item \(x - 2 = 0\)
+ \item \(x - 3 = 0\)
+ \item Therefore \(x = 2\) or \(x = 3\)
+\end{enumerate}
+```
+
+## Frontend MVP
+
+Add a practice-problem block editor to `frontend/src/components/CreateCheatSheet.jsx` using the existing drag-and-drop pattern.
+
+Each block should support:
+
+- label input
+- source textarea
+- compile/save status
+- drag handle
+- delete button
+- reorder persistence through `order`
+
+Suggested initial UX:
+
+- left side: list of problem blocks
+- block card header: label + drag handle + remove action
+- block body: syntax textarea
+- optional future enhancement: compiled preview panel per block
+
+## API shape for MVP
+
+### Create/update payload
+
+```json
+{
+ "cheat_sheet": 1,
+ "label": "Quadratic factoring",
+ "source_format": "simple_v1",
+ "source_text": "problem:\n\ttext: Solve for x\n\tmath: x^2 - 5x + 6 = 0\n\nsteps:\n\ttext: Factor the trinomial\n\tmath: x^2 - 5x + 6 = (x - 2)(x - 3)",
+ "order": 1
+}
+```
+
+### Response fields
+
+```json
+{
+ "id": 10,
+ "cheat_sheet": 1,
+ "label": "Quadratic factoring",
+ "source_format": "simple_v1",
+ "source_text": "...",
+ "compiled_latex": "...",
+ "order": 1
+}
+```
+
+### Validation errors
+
+Should be line-aware when possible, for example:
+
+```json
+{
+ "source_text": [
+ "Line 5: unknown key 'answer'. Expected 'text:' or 'math:' inside steps."
+ ]
+}
+```
+
+## Testing plan
+
+### Compiler unit tests
+
+- valid `problem + steps` source compiles successfully
+- unknown top-level key fails
+- mixed indentation fails
+- missing `problem:` fails
+- missing `steps:` fails
+- unsupported math token fails with readable error
+
+### Serializer/API tests
+
+- creating compiler-backed problem stores `compiled_latex`
+- updating `source_text` recompiles and replaces stored LaTeX
+- invalid source returns `400` with line-aware errors
+- legacy rows still serialize and render
+
+### Integration tests
+
+- cheat sheet `full_latex` includes compiled block LaTeX
+- compile endpoint still produces PDF with compiler-backed blocks
+- ordering of blocks is preserved
+
+## Risks to avoid
+
+1. Turning MVP into a full programming language
+2. Designing one giant source field containing many named blocks
+3. Recompiling on every document build instead of storing stable output
+4. Adding nested models for steps before the syntax proves it is needed
+5. Using heuristics to guess whether a free-form line is text or math
+
+## Suggested implementation order
+
+1. Add model fields and migration
+2. Add compiler service and pure unit tests
+3. Update serializer and API tests
+4. Update cheat sheet LaTeX assembly for compiler-backed blocks
+5. Add frontend block editor and reorder UI
+6. Verify full save → retrieve → compile flow
+
+## Open assumptions for MVP
+
+- Block label is a separate UI/API field, not part of the source grammar.
+- Each block represents one problem with ordered steps.
+- `steps:` is required in MVP even if later versions may relax that.
+- `simple_v1` favors explicit `text:` / `math:` lines over heuristic parsing.
From 4e077fea29cfea225fab524738c61003812c942a Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 21:10:15 -0700
Subject: [PATCH 06/15] Add a practice problem compiler service
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
backend/api/services/__init__.py | 5 +
.../api/services/practice_problem_compiler.py | 370 ++++++++++++++++++
backend/api/test_practice_problem_compiler.py | 90 +++++
3 files changed, 465 insertions(+)
create mode 100644 backend/api/services/__init__.py
create mode 100644 backend/api/services/practice_problem_compiler.py
create mode 100644 backend/api/test_practice_problem_compiler.py
diff --git a/backend/api/services/__init__.py b/backend/api/services/__init__.py
new file mode 100644
index 0000000..916f1a0
--- /dev/null
+++ b/backend/api/services/__init__.py
@@ -0,0 +1,5 @@
+from .practice_problem_compiler import CompilationResult as CompilationResult
+from .practice_problem_compiler import CompilerError as CompilerError
+from .practice_problem_compiler import compile_source as compile_source
+
+__all__ = ["CompilationResult", "CompilerError", "compile_source"]
diff --git a/backend/api/services/practice_problem_compiler.py b/backend/api/services/practice_problem_compiler.py
new file mode 100644
index 0000000..0eb0a10
--- /dev/null
+++ b/backend/api/services/practice_problem_compiler.py
@@ -0,0 +1,370 @@
+from dataclasses import dataclass, field
+import re
+
+
+ALLOWED_TOP_LEVEL_KEYS = {"problem", "steps"}
+ALLOWED_CHILD_KEYS = {"text", "math"}
+ALLOWED_MATH_CHARACTERS = set(
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 +-*/^=().,!<>_[]"
+)
+
+
+@dataclass
+class CompilerError:
+ line: int
+ column: int
+ message: str
+
+
+@dataclass
+class ProblemItem:
+ kind: str
+ value: str
+ line: int
+
+
+@dataclass
+class ParsedProblemBlock:
+ problem_items: list[ProblemItem] = field(default_factory=list)
+ step_items: list[ProblemItem] = field(default_factory=list)
+
+
+@dataclass
+class CompilationResult:
+ compiled_latex: str = ""
+ errors: list[CompilerError] = field(default_factory=list)
+
+ @property
+ def is_valid(self):
+ return not self.errors
+
+
+def compile_source(source_text: str, label: str = "") -> CompilationResult:
+ parsed_block, errors = parse_source(source_text)
+ if errors:
+ return CompilationResult(errors=errors)
+
+ compiled_lines = []
+ escaped_label = _escape_latex_text(label)
+ compilation_errors = []
+
+ if escaped_label:
+ compiled_lines.append(f"\\subsection*{{{escaped_label}}}")
+
+ compiled_lines.append("\\textbf{Problem.}")
+ compiled_lines.append("")
+
+ for item in parsed_block.problem_items:
+ try:
+ compiled_lines.extend(_compile_problem_item(item))
+ except ValueError as error:
+ compilation_errors.append(
+ CompilerError(line=item.line, column=1, message=str(error))
+ )
+
+ compiled_lines.append("")
+ compiled_lines.append("\\textbf{Steps.}")
+ compiled_lines.append("\\begin{enumerate}")
+ for item in parsed_block.step_items:
+ try:
+ compiled_lines.append(f" \\item {_compile_step_item(item)}")
+ except ValueError as error:
+ compilation_errors.append(
+ CompilerError(line=item.line, column=1, message=str(error))
+ )
+ compiled_lines.append("\\end{enumerate}")
+
+ if compilation_errors:
+ return CompilationResult(errors=compilation_errors)
+
+ return CompilationResult(compiled_latex="\n".join(compiled_lines).strip())
+
+
+def parse_source(source_text: str) -> tuple[ParsedProblemBlock, list[CompilerError]]:
+ parsed = ParsedProblemBlock()
+ errors = []
+
+ normalized_source = (source_text or "").replace("\r\n", "\n").replace("\r", "\n")
+ if not normalized_source.strip():
+ return parsed, [CompilerError(line=1, column=1, message="Source text cannot be empty.")]
+
+ lines = normalized_source.split("\n")
+ indent_style = _detect_indent_style(lines)
+ if indent_style == "mixed":
+ return parsed, [
+ CompilerError(
+ line=_find_first_mixed_indent_line(lines),
+ column=1,
+ message="Mixed tabs and spaces are not allowed in the same block.",
+ )
+ ]
+
+ current_section = None
+ child_indent = {}
+
+ for line_number, raw_line in enumerate(lines, start=1):
+ if not raw_line.strip():
+ continue
+
+ indent = _leading_whitespace(raw_line)
+ stripped_line = raw_line.lstrip(" \t")
+
+ if not indent:
+ if not stripped_line.endswith(":"):
+ errors.append(
+ CompilerError(
+ line=line_number,
+ column=len(raw_line) - len(stripped_line) + 1,
+ message="Top-level lines must end with ':' and declare 'problem:' or 'steps:'.",
+ )
+ )
+ continue
+
+ section_name = stripped_line[:-1].strip()
+ if section_name not in ALLOWED_TOP_LEVEL_KEYS:
+ errors.append(
+ CompilerError(
+ line=line_number,
+ column=1,
+ message=f"Unknown top-level key '{section_name}'. Expected 'problem:' or 'steps:'.",
+ )
+ )
+ current_section = None
+ continue
+
+ if child_indent.get(section_name) is not None:
+ errors.append(
+ CompilerError(
+ line=line_number,
+ column=1,
+ message=f"Duplicate top-level section '{section_name}:'.",
+ )
+ )
+ current_section = None
+ continue
+
+ current_section = section_name
+ child_indent[section_name] = ""
+ continue
+
+ if current_section is None:
+ errors.append(
+ CompilerError(
+ line=line_number,
+ column=1,
+ message="Indented lines must appear under 'problem:' or 'steps:'.",
+ )
+ )
+ continue
+
+ if not child_indent[current_section]:
+ child_indent[current_section] = indent
+ elif indent != child_indent[current_section]:
+ errors.append(
+ CompilerError(
+ line=line_number,
+ column=1,
+ message=f"All lines inside '{current_section}:' must use the same indentation level.",
+ )
+ )
+ continue
+
+ if ":" not in stripped_line:
+ errors.append(
+ CompilerError(
+ line=line_number,
+ column=len(indent) + 1,
+ message="Child lines must start with 'text:' or 'math:'.",
+ )
+ )
+ continue
+
+ item_kind, item_value = stripped_line.split(":", 1)
+ item_kind = item_kind.strip()
+ item_value = item_value.strip()
+
+ if item_kind not in ALLOWED_CHILD_KEYS:
+ errors.append(
+ CompilerError(
+ line=line_number,
+ column=len(indent) + 1,
+ message=(
+ f"Unknown key '{item_kind}'. Expected 'text:' or 'math:' inside {current_section}."
+ ),
+ )
+ )
+ continue
+
+ if not item_value:
+ errors.append(
+ CompilerError(
+ line=line_number,
+ column=len(indent) + len(item_kind) + 2,
+ message=f"{item_kind.title()} lines cannot be empty.",
+ )
+ )
+ continue
+
+ target_items = parsed.problem_items if current_section == "problem" else parsed.step_items
+ target_items.append(ProblemItem(kind=item_kind, value=item_value, line=line_number))
+
+ if errors:
+ return parsed, errors
+
+ if not parsed.problem_items:
+ errors.append(
+ CompilerError(
+ line=1,
+ column=1,
+ message="A 'problem:' section with at least one child line is required.",
+ )
+ )
+
+ if not parsed.step_items:
+ errors.append(
+ CompilerError(
+ line=1,
+ column=1,
+ message="A 'steps:' section with at least one child line is required.",
+ )
+ )
+
+ return parsed, errors
+
+
+def _compile_problem_item(item: ProblemItem) -> list[str]:
+ if item.kind == "math":
+ return ["\\[", compile_math_expression(item.value), "\\]", ""]
+
+ return [_escape_latex_text(item.value), ""]
+
+
+def _compile_step_item(item: ProblemItem) -> str:
+ if item.kind == "math":
+ return f"\\({compile_math_expression(item.value)}\\)"
+
+ return _escape_latex_text(item.value)
+
+
+def compile_math_expression(expression: str) -> str:
+ value = (expression or "").strip()
+ if not value:
+ raise ValueError("Math expressions cannot be empty.")
+
+ invalid_characters = sorted({character for character in value if character not in ALLOWED_MATH_CHARACTERS})
+ if invalid_characters:
+ invalid_display = "".join(invalid_characters)
+ raise ValueError(f"Unsupported character(s) in math expression: {invalid_display}")
+
+ compiled = _replace_sqrt_calls(value)
+ compiled = compiled.replace("<=", r" \le ")
+ compiled = compiled.replace(">=", r" \ge ")
+ compiled = compiled.replace("!=", r" \ne ")
+ compiled = re.sub(r"(?!=])=(?!=)", " = ", compiled)
+ compiled = re.sub(r"\s*\*\s*", r" \\cdot ", compiled)
+ compiled = re.sub(r"\s+", " ", compiled).strip()
+ return compiled
+
+
+def _replace_sqrt_calls(value: str) -> str:
+ result = []
+ index = 0
+
+ while index < len(value):
+ if value.startswith("sqrt(", index):
+ expression, next_index = _extract_parenthesized_expression(value, index + 4)
+ result.append(rf"\sqrt{{{compile_math_expression(expression)}}}")
+ index = next_index
+ continue
+
+ result.append(value[index])
+ index += 1
+
+ return "".join(result)
+
+
+def _extract_parenthesized_expression(value: str, start_index: int) -> tuple[str, int]:
+ if start_index >= len(value) or value[start_index] != "(":
+ raise ValueError("sqrt must be followed by parentheses, like sqrt(x + 1).")
+
+ depth = 1
+ index = start_index + 1
+ collected = []
+
+ while index < len(value):
+ character = value[index]
+ if character == "(":
+ depth += 1
+ elif character == ")":
+ depth -= 1
+ if depth == 0:
+ return "".join(collected).strip(), index + 1
+
+ if depth > 0:
+ collected.append(character)
+ index += 1
+
+ raise ValueError("Unclosed parentheses in sqrt expression.")
+
+
+def _escape_latex_text(value: str) -> str:
+ escaped = value.replace("\\", r"\textbackslash{}")
+ replacements = {
+ "&": r"\&",
+ "%": r"\%",
+ "$": r"\$",
+ "#": r"\#",
+ "_": r"\_",
+ "{": r"\{",
+ "}": r"\}",
+ "~": r"\textasciitilde{}",
+ "^": r"\textasciicircum{}",
+ }
+ for original, replacement in replacements.items():
+ escaped = escaped.replace(original, replacement)
+ return escaped
+
+
+def _detect_indent_style(lines: list[str]) -> str:
+ saw_tabs = False
+ saw_spaces = False
+
+ for line in lines:
+ indent = _leading_whitespace(line)
+ if not indent:
+ continue
+
+ if "\t" in indent:
+ saw_tabs = True
+ if " " in indent:
+ saw_spaces = True
+
+ if saw_tabs and saw_spaces:
+ return "mixed"
+ if saw_tabs:
+ return "tabs"
+ return "spaces"
+
+
+def _find_first_mixed_indent_line(lines: list[str]) -> int:
+ saw_tabs = False
+ saw_spaces = False
+
+ for line_number, line in enumerate(lines, start=1):
+ indent = _leading_whitespace(line)
+ if not indent:
+ continue
+
+ if "\t" in indent:
+ saw_tabs = True
+ if " " in indent:
+ saw_spaces = True
+
+ if saw_tabs and saw_spaces:
+ return line_number
+
+ return 1
+
+
+def _leading_whitespace(value: str) -> str:
+ return value[: len(value) - len(value.lstrip(" \t"))]
diff --git a/backend/api/test_practice_problem_compiler.py b/backend/api/test_practice_problem_compiler.py
new file mode 100644
index 0000000..969a887
--- /dev/null
+++ b/backend/api/test_practice_problem_compiler.py
@@ -0,0 +1,90 @@
+from api.services.practice_problem_compiler import compile_source
+
+
+VALID_SOURCE = """problem:
+ text: Solve for x
+ math: x^2 - 5x + 6 = 0
+
+steps:
+ text: Factor the trinomial
+ math: x^2 - 5x + 6 = (x - 2)(x - 3)
+ text: Therefore x = 2 or x = 3
+"""
+
+
+def test_compile_source_builds_latex_from_simple_v1_block():
+ result = compile_source(VALID_SOURCE, label="Quadratic factoring")
+
+ assert result.is_valid
+ assert "\\subsection*{Quadratic factoring}" in result.compiled_latex
+ assert "\\textbf{Problem.}" in result.compiled_latex
+ assert "\\textbf{Steps.}" in result.compiled_latex
+ assert "\\[" in result.compiled_latex
+ assert "Therefore x = 2 or x = 3" in result.compiled_latex
+ assert "\\begin{enumerate}" in result.compiled_latex
+
+
+def test_compile_source_treats_text_lines_as_literal_text():
+ result = compile_source(
+ """problem:
+ text: Area of a circle
+
+steps:
+ text: A factor is x - 2
+"""
+ )
+
+ assert result.is_valid
+ assert "Area of a circle" in result.compiled_latex
+ assert "A factor is x - 2" in result.compiled_latex
+ assert "\\(" not in result.compiled_latex
+
+
+def test_compile_source_rejects_unknown_top_level_key():
+ result = compile_source(
+ """problem:
+ text: Solve for x
+
+answer:
+ math: x = 2
+"""
+ )
+
+ assert not result.is_valid
+ assert result.errors[0].line == 4
+ assert "Unknown top-level key 'answer'" in result.errors[0].message
+
+
+def test_compile_source_rejects_mixed_indentation():
+ result = compile_source(
+ "problem:\n\ttext: Solve for x\n math: x^2 - 5x + 6 = 0\n\nsteps:\n\tmath: x = 2"
+ )
+
+ assert not result.is_valid
+ assert result.errors[0].message == "Mixed tabs and spaces are not allowed in the same block."
+
+
+def test_compile_source_requires_problem_and_steps_sections():
+ result = compile_source(
+ """problem:
+ text: Solve for x
+"""
+ )
+
+ assert not result.is_valid
+ assert any("'steps:' section" in error.message for error in result.errors)
+
+
+def test_compile_source_rejects_unsupported_math_characters():
+ result = compile_source(
+ """problem:
+ math: x + y
+
+steps:
+ math: x @ y
+"""
+ )
+
+ assert not result.is_valid
+ assert result.errors[0].line == 5
+ assert "Unsupported character(s) in math expression: @" in result.errors[0].message
From 24b25ab9e912a53e988ba55f38291bc09cc6baaa Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 21:10:24 -0700
Subject: [PATCH 07/15] Add compiled practice problem fields
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
...ed_latex_practiceproblem_label_and_more.py | 50 +++++++++++++++++++
backend/api/models.py | 25 +++++++---
.../api/test_practice_problem_rendering.py | 26 ++++++++++
3 files changed, 94 insertions(+), 7 deletions(-)
create mode 100644 backend/api/migrations/0003_practiceproblem_compiled_latex_practiceproblem_label_and_more.py
create mode 100644 backend/api/test_practice_problem_rendering.py
diff --git a/backend/api/migrations/0003_practiceproblem_compiled_latex_practiceproblem_label_and_more.py b/backend/api/migrations/0003_practiceproblem_compiled_latex_practiceproblem_label_and_more.py
new file mode 100644
index 0000000..600bf5c
--- /dev/null
+++ b/backend/api/migrations/0003_practiceproblem_compiled_latex_practiceproblem_label_and_more.py
@@ -0,0 +1,50 @@
+# Generated by Django 6.0.4 on 2026-04-20 04:00
+
+from django.db import migrations, models
+
+
+def backfill_legacy_source_format(apps, schema_editor):
+ PracticeProblem = apps.get_model('api', 'PracticeProblem')
+ PracticeProblem.objects.filter(
+ source_text='',
+ compiled_latex='',
+ ).exclude(
+ question_latex='',
+ answer_latex='',
+ ).update(source_format='latex_legacy')
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0002_cheatsheet_selected_formulas'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='practiceproblem',
+ name='compiled_latex',
+ field=models.TextField(blank=True, default=''),
+ ),
+ migrations.AddField(
+ model_name='practiceproblem',
+ name='label',
+ field=models.CharField(blank=True, default='', max_length=120),
+ ),
+ migrations.AddField(
+ model_name='practiceproblem',
+ name='source_format',
+ field=models.CharField(default='simple_v1', max_length=20),
+ ),
+ migrations.AddField(
+ model_name='practiceproblem',
+ name='source_text',
+ field=models.TextField(blank=True, default=''),
+ ),
+ migrations.AlterField(
+ model_name='practiceproblem',
+ name='question_latex',
+ field=models.TextField(blank=True, default=''),
+ ),
+ migrations.RunPython(backfill_legacy_source_format, migrations.RunPython.noop),
+ ]
diff --git a/backend/api/models.py b/backend/api/models.py
index 500dcf2..4d92403 100644
--- a/backend/api/models.py
+++ b/backend/api/models.py
@@ -39,12 +39,10 @@ def _build_practice_problems_section(self):
section_lines = ["\\section*{Practice Problems}"]
for problem in problems:
- section_lines.append(
- f"\\textbf{{Problem {problem.order}:}} {problem.question_latex}"
- )
- if problem.answer_latex:
- section_lines.append(f"\\textbf{{Answer:}} {problem.answer_latex}")
- section_lines.append("")
+ rendered_latex = problem.get_rendered_latex()
+ if rendered_latex:
+ section_lines.append(rendered_latex)
+ section_lines.append("")
return "\n".join(section_lines).rstrip()
@@ -132,7 +130,11 @@ class PracticeProblem(models.Model):
cheat_sheet = models.ForeignKey(
CheatSheet, on_delete=models.CASCADE, related_name="problems"
)
- question_latex = models.TextField()
+ label = models.CharField(max_length=120, blank=True, default="")
+ source_format = models.CharField(max_length=20, default="simple_v1")
+ source_text = models.TextField(blank=True, default="")
+ compiled_latex = models.TextField(blank=True, default="")
+ question_latex = models.TextField(blank=True, default="")
answer_latex = models.TextField(blank=True, default="")
order = models.IntegerField(default=0)
@@ -141,3 +143,12 @@ class Meta:
def __str__(self):
return f"Problem {self.order} - {self.cheat_sheet.title}"
+
+ def get_rendered_latex(self):
+ if self.compiled_latex:
+ return self.compiled_latex.strip()
+
+ lines = [f"\\textbf{{Problem {self.order}:}} {self.question_latex}"]
+ if self.answer_latex:
+ lines.append(f"\\textbf{{Answer:}} {self.answer_latex}")
+ return "\n".join(lines).rstrip()
diff --git a/backend/api/test_practice_problem_rendering.py b/backend/api/test_practice_problem_rendering.py
new file mode 100644
index 0000000..8c2c452
--- /dev/null
+++ b/backend/api/test_practice_problem_rendering.py
@@ -0,0 +1,26 @@
+import pytest
+
+from api.models import CheatSheet, PracticeProblem
+
+
+@pytest.mark.django_db
+def test_build_full_latex_prefers_compiled_problem_blocks():
+ sheet = CheatSheet.objects.create(
+ title="With Compiled Blocks",
+ latex_content="Content",
+ )
+ PracticeProblem.objects.create(
+ cheat_sheet=sheet,
+ label="Quadratic factoring",
+ source_format="simple_v1",
+ source_text="problem:\n text: Solve for x\n math: x^2 - 5x + 6 = 0\n\nsteps:\n math: x = 2",
+ compiled_latex="\\subsection*{Quadratic factoring}\nCompiled block content",
+ question_latex="Legacy question",
+ answer_latex="Legacy answer",
+ order=1,
+ )
+
+ full = sheet.build_full_latex()
+
+ assert "Compiled block content" in full
+ assert "Legacy question" not in full
From 95e8c7963f5f5ac7ac2e72455ed3dd332344daff Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 21:10:30 -0700
Subject: [PATCH 08/15] Compile practice problem source in the serializer
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
backend/api/serializers.py | 77 +++++++++++++++++++++++++++++++++++---
backend/api/tests.py | 64 ++++++++++++++++++++++++++++++-
2 files changed, 135 insertions(+), 6 deletions(-)
diff --git a/backend/api/serializers.py b/backend/api/serializers.py
index 7d8b0f5..d72a0d9 100644
--- a/backend/api/serializers.py
+++ b/backend/api/serializers.py
@@ -1,6 +1,7 @@
-# DRF serializers for the backend API will be added here.
from rest_framework import serializers
+
from .models import Template, CheatSheet, PracticeProblem
+from .services.practice_problem_compiler import compile_source
class TemplateSerializer(serializers.ModelSerializer):
@@ -26,11 +27,77 @@ class Meta:
fields = [
"id",
"cheat_sheet",
+ "label",
+ "source_format",
+ "source_text",
+ "compiled_latex",
"question_latex",
"answer_latex",
"order",
]
- read_only_fields = ["id"]
+ read_only_fields = ["id", "compiled_latex"]
+ extra_kwargs = {
+ "question_latex": {"required": False, "allow_blank": True},
+ "answer_latex": {"required": False, "allow_blank": True},
+ "source_text": {"required": False, "allow_blank": True},
+ "label": {"required": False, "allow_blank": True},
+ "source_format": {"required": False},
+ }
+
+ def validate(self, attrs):
+ attrs = super().validate(attrs)
+
+ instance = getattr(self, "instance", None)
+ submitted_legacy_fields = any(
+ field in attrs for field in ["question_latex", "answer_latex"]
+ )
+ label = attrs.get("label", getattr(instance, "label", ""))
+ source_format = attrs.get(
+ "source_format",
+ getattr(instance, "source_format", "simple_v1"),
+ )
+ source_text = attrs.get("source_text", getattr(instance, "source_text", ""))
+
+ if source_format not in {"simple_v1", "latex_legacy"}:
+ raise serializers.ValidationError(
+ {"source_format": ["Unsupported source format. Use 'simple_v1' or 'latex_legacy'."]}
+ )
+
+ if submitted_legacy_fields:
+ raise serializers.ValidationError(
+ {
+ "question_latex": [
+ "Direct LaTeX problem fields are read-only. Use source_text with simple_v1 instead."
+ ]
+ }
+ )
+
+ if source_text:
+ if source_format != "simple_v1":
+ raise serializers.ValidationError(
+ {"source_format": ["source_text currently supports only 'simple_v1'."]}
+ )
+
+ result = compile_source(source_text, label=label)
+ if not result.is_valid:
+ raise serializers.ValidationError(
+ {
+ "source_text": [
+ f"Line {error.line}: {error.message}" for error in result.errors
+ ]
+ }
+ )
+
+ attrs["source_format"] = "simple_v1"
+ attrs["compiled_latex"] = result.compiled_latex
+ return attrs
+
+ if source_format == "simple_v1":
+ raise serializers.ValidationError(
+ {"source_text": ["Provide source_text for simple_v1 problems."]}
+ )
+
+ return attrs
class CheatSheetSerializer(serializers.ModelSerializer):
@@ -66,9 +133,9 @@ class CompileRequestSerializer(serializers.Serializer):
content = serializers.CharField(required=False, default="")
cheat_sheet_id = serializers.IntegerField(required=False, default=None)
- def validate(self, data):
- if not data.get("content") and not data.get("cheat_sheet_id"):
+ def validate(self, attrs):
+ if not attrs.get("content") and not attrs.get("cheat_sheet_id"):
raise serializers.ValidationError(
"Provide either 'content' or 'cheat_sheet_id'."
)
- return data
+ return attrs
diff --git a/backend/api/tests.py b/backend/api/tests.py
index ecaad92..7c4ecf1 100644
--- a/backend/api/tests.py
+++ b/backend/api/tests.py
@@ -42,6 +42,7 @@ def sample_sheet(db, sample_template):
def sample_problem(db, sample_sheet):
return PracticeProblem.objects.create(
cheat_sheet=sample_sheet,
+ source_format="latex_legacy",
question_latex="What is $2 + 2$?",
answer_latex="$4$",
order=1,
@@ -274,7 +275,7 @@ def test_create_from_template_missing_id(self, api_client):
@pytest.mark.django_db
class TestPracticeProblemAPI:
- def test_create_problem(self, api_client, sample_sheet):
+ def test_create_problem_rejects_legacy_latex_fields(self, api_client, sample_sheet):
resp = api_client.post(
"/api/problems/",
{
@@ -285,13 +286,74 @@ def test_create_problem(self, api_client, sample_sheet):
},
format="json",
)
+ assert resp.status_code == 400
+ assert "question_latex" in resp.json()
+
+ def test_update_problem_rejects_legacy_latex_fields(self, api_client, sample_problem):
+ resp = api_client.patch(
+ f"/api/problems/{sample_problem.id}/",
+ {"question_latex": "Changed raw latex"},
+ format="json",
+ )
+
+ assert resp.status_code == 400
+ assert "question_latex" in resp.json()
+
+ def test_create_simple_v1_problem_compiles_source(self, api_client, sample_sheet):
+ resp = api_client.post(
+ "/api/problems/",
+ {
+ "cheat_sheet": sample_sheet.id,
+ "label": "Quadratic factoring",
+ "source_format": "simple_v1",
+ "source_text": (
+ "problem:\n"
+ " text: Solve for x\n"
+ " math: x^2 - 5x + 6 = 0\n\n"
+ "steps:\n"
+ " text: Factor the trinomial\n"
+ " math: x^2 - 5x + 6 = (x - 2)(x - 3)"
+ ),
+ "order": 1,
+ },
+ format="json",
+ )
+
assert resp.status_code == 201
+ data = resp.json()
+ assert data["source_format"] == "simple_v1"
+ assert "\\subsection*{Quadratic factoring}" in data["compiled_latex"]
+ assert data["question_latex"] == ""
+ assert data["answer_latex"] == ""
+
+ def test_create_simple_v1_problem_returns_line_aware_validation_errors(self, api_client, sample_sheet):
+ resp = api_client.post(
+ "/api/problems/",
+ {
+ "cheat_sheet": sample_sheet.id,
+ "label": "Broken block",
+ "source_format": "simple_v1",
+ "source_text": "problem:\n text: Solve for x\n\nanswer:\n math: x = 2",
+ "order": 1,
+ },
+ format="json",
+ )
+
+ assert resp.status_code == 400
+ assert "source_text" in resp.json()
+ assert "Line 4" in resp.json()["source_text"][0]
def test_filter_problems_by_sheet(self, api_client, sample_problem, sample_sheet):
resp = api_client.get(f"/api/problems/?cheat_sheet={sample_sheet.id}")
assert resp.status_code == 200
assert len(resp.json()) >= 1
+ def test_list_problem_preserves_legacy_source_format(self, api_client, sample_problem, sample_sheet):
+ resp = api_client.get(f"/api/problems/?cheat_sheet={sample_sheet.id}")
+
+ assert resp.status_code == 200
+ assert resp.json()[0]["source_format"] == "latex_legacy"
+
@pytest.mark.django_db
class TestGenerateSheetEndpoint:
From d8d1e9a2e010e0b27d432f61afa18807433f3ce9 Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 21:27:32 -0700
Subject: [PATCH 09/15] Save practice problem blocks from the frontend
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
frontend/src/App.jsx | 16 +-
frontend/src/components/CreateCheatSheet.jsx | 201 +++++++++++++-
frontend/src/hooks/latex.js | 48 +++-
frontend/src/hooks/practiceProblems.js | 259 +++++++++++++++++++
4 files changed, 505 insertions(+), 19 deletions(-)
create mode 100644 frontend/src/hooks/practiceProblems.js
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 6061728..d2a3424 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -10,6 +10,7 @@ const DEFAULT_SHEET = {
spacing: 'large',
margins: '0.25in',
selectedFormulas: [],
+ practiceProblems: [],
};
function App() {
@@ -59,17 +60,19 @@ function App() {
}
}, []);
- const handleSave = async (data, showFeedback = true) => {
+ const handleSave = async (data, options = {}) => {
+ const { showFeedback = true, persistOnly = false } = options;
const nextSheet = {
...cheatSheet,
...data,
selectedFormulas: data.selectedFormulas ?? cheatSheet.selectedFormulas ?? [],
+ practiceProblems: data.practiceProblems ?? cheatSheet.practiceProblems ?? [],
};
setCheatSheet(nextSheet);
localStorage.setItem('currentCheatSheet', JSON.stringify(nextSheet));
- if (!showFeedback) {
+ if (persistOnly) {
return nextSheet;
}
@@ -102,15 +105,20 @@ function App() {
content: savedSheet.latex_content ?? nextSheet.content,
fontSize: savedSheet.font_size ?? nextSheet.fontSize,
selectedFormulas: savedSheet.selected_formulas ?? nextSheet.selectedFormulas,
+ practiceProblems: savedSheet.problems ?? nextSheet.practiceProblems,
};
setCheatSheet(persistedSheet);
localStorage.setItem('currentCheatSheet', JSON.stringify(persistedSheet));
- alert('Progress saved!');
+ if (showFeedback) {
+ alert('Progress saved!');
+ }
return persistedSheet;
} catch (error) {
console.error('Failed to save cheat sheet', error);
- alert(`Failed to save progress: ${error.message}`);
+ if (showFeedback) {
+ alert(`Failed to save progress: ${error.message}`);
+ }
throw error;
} finally {
setIsSaving(false);
diff --git a/frontend/src/components/CreateCheatSheet.jsx b/frontend/src/components/CreateCheatSheet.jsx
index 87fb007..7172316 100644
--- a/frontend/src/components/CreateCheatSheet.jsx
+++ b/frontend/src/components/CreateCheatSheet.jsx
@@ -3,6 +3,7 @@ import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, us
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useFormulas } from '../hooks/formulas';
+import { usePracticeProblems } from '../hooks/practiceProblems';
import { useLatex } from '../hooks/latex';
import { Document, Page, pdfjs } from 'react-pdf';
@@ -313,6 +314,133 @@ const FormulaSelection = ({
);
+function SortableProblemBlock({ problem, onChange, onRemove }) {
+ const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({
+ id: problem.clientId,
+ data: { type: 'problem' },
+ });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ opacity: isDragging ? 0.8 : 1,
+ zIndex: isDragging ? 1000 : 'auto',
+ position: 'relative',
+ boxShadow: isDragging ? '0 4px 12px rgba(0,0,0,0.15)' : 'none',
+ };
+
+ return (
+
+
+
+
⋮⋮
+
+
{problem.label?.trim() || 'Untitled problem block'}
+
Drag to set block order in the saved PDF
+
+
+
onRemove(problem.clientId)}>
+ Delete
+
+
+
+
+
+ Block label
+ onChange(problem.clientId, 'label', event.target.value)}
+ placeholder="Quadratic factoring"
+ className="input-field practice-problem-input"
+ />
+
+
+
+ Problem source
+
+
+
+ {problem.errors?.length > 0 && (
+
+ {problem.errors.map((error) => (
+
{error}
+ ))}
+
+ )}
+
+ );
+}
+
+function PracticeProblemEditor({ problems, onAddProblem, onChangeProblem, onRemoveProblem, onReorderProblems }) {
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
+ );
+
+ const handleDragEnd = ({ active, over }) => {
+ if (!over || active.id === over.id) {
+ return;
+ }
+
+ const oldIndex = problems.findIndex((problem) => problem.clientId === active.id);
+ const newIndex = problems.findIndex((problem) => problem.clientId === over.id);
+
+ if (oldIndex !== -1 && newIndex !== -1) {
+ onReorderProblems(oldIndex, newIndex);
+ }
+ };
+
+ return (
+
+
+
+
Practice problem blocks
+
+ Author problems in simple_v1, drag them into order, and save to include them in the compiled PDF preview.
+
+
+
Add problem block
+
+
+
+ Syntax help
+ {`problem:\n text: Solve for x\n math: x^2 - 5x + 6 = 0\n\nsteps:\n text: Factor the trinomial\n math: x^2 - 5x + 6 = (x - 2)(x - 3)\n text: Therefore x = 2 or x = 3`}
+
+
+ {problems.length === 0 ? (
+
+ No practice problems yet. Add a block to start writing compiler-backed problems.
+
+ ) : (
+
+ problem.clientId)} strategy={verticalListSortingStrategy}>
+
+ {problems.map((problem) => (
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
const COMPILE_ERROR_LINE_REGEX = /document\.tex:(\d+):/g;
const escapeHtml = (value = '') => value
@@ -498,7 +626,7 @@ const PdfPreview = ({ pdfBlob, compileError }) => {
);
};
-const ActionToolbar = ({ handleDownloadTex, handleDownloadPDF, isLoading, isSaving, content, handleClear }) => (
+const ActionToolbar = ({ handleDownloadTex, handleDownloadPDF, isLoading, isSaving, canDownloadPDF, handleClear }) => (
{isSaving ? 'Saving...' : 'Save Progress'}
Download .tex
@@ -506,7 +634,7 @@ const ActionToolbar = ({ handleDownloadTex, handleDownloadPDF, isLoading, isSavi
type="button"
onClick={handleDownloadPDF}
className="btn download"
- disabled={isLoading || !content}
+ disabled={isLoading || !canDownloadPDF}
>
{isLoading ? 'Compiling...' : 'Download PDF'}
@@ -599,6 +727,17 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
hasSelectedClasses
} = useFormulas(initialData);
+ const {
+ problems,
+ addProblem,
+ updateProblem,
+ removeProblem,
+ reorderProblems,
+ clearProblems,
+ serializeProblems,
+ syncProblems,
+ } = usePracticeProblems(initialData);
+
const {
title,
setTitle,
@@ -624,6 +763,7 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
goForward,
handleGenerateSheet,
handleCompileOnly,
+ compileSavedSheet,
handleDownloadPDF,
handleDownloadTex,
clearLatex
@@ -640,21 +780,48 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
const handleSave = async (e) => {
e.preventDefault();
- await onSave({
- title,
- content,
- columns,
- fontSize,
- spacing,
- margins,
- selectedFormulas: getSelectedFormulasList(),
- });
+ try {
+ const basePayload = {
+ title,
+ content,
+ columns,
+ fontSize,
+ spacing,
+ margins,
+ selectedFormulas: getSelectedFormulasList(),
+ practiceProblems: serializeProblems(),
+ };
+
+ const persistedSheet = await onSave(basePayload, { showFeedback: false });
+ const persistedProblems = await syncProblems(persistedSheet.id);
+ await onSave(
+ {
+ ...basePayload,
+ id: persistedSheet.id,
+ practiceProblems: persistedProblems.map((problem) => ({
+ id: problem.id,
+ label: problem.label,
+ source_text: problem.sourceText,
+ source_format: problem.sourceFormat,
+ compiled_latex: problem.compiledLatex,
+ order: problem.order,
+ })),
+ },
+ { persistOnly: true }
+ );
+ await compileSavedSheet(persistedSheet.id);
+ alert('Progress saved!');
+ } catch (error) {
+ console.error('Failed to save practice problems', error);
+ alert(`Failed to save progress: ${error.message}`);
+ }
};
const handleClear = () => {
if (window.confirm('Are you sure you want to clear everything? This cannot be undone.')) {
clearLatex();
clearSelections();
+ clearProblems();
onReset?.();
}
};
@@ -692,6 +859,14 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
onRemoveClass={removeClassFromOrder}
onRemoveFormula={removeSingleFormula}
/>
+
+
{/* Box 2: Editor and preview */}
@@ -753,10 +928,10 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
handleDownloadPDF(initialData?.id)}
isLoading={isLoading}
isSaving={isSaving}
- content={content}
+ canDownloadPDF={Boolean(content || initialData?.id)}
handleClear={handleClear}
/>
diff --git a/frontend/src/hooks/latex.js b/frontend/src/hooks/latex.js
index 73d6fde..1965241 100644
--- a/frontend/src/hooks/latex.js
+++ b/frontend/src/hooks/latex.js
@@ -158,6 +158,45 @@ export function useLatex(initialData) {
setPdfBlob(pdfBlobUrlRef.current);
}, []);
+ const compileSavedSheet = useCallback(async (cheatSheetId) => {
+ if (!cheatSheetId) {
+ throw new Error('Save the cheat sheet first.');
+ }
+
+ if (isCompilingRef.current) return;
+
+ isCompilingRef.current = true;
+ setIsCompiling(true);
+ setCompileError(null);
+
+ try {
+ const response = await fetch('/api/compile/', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ cheat_sheet_id: cheatSheetId }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(formatCompileError(errorData));
+ }
+
+ const blob = await response.blob();
+ if (pdfBlobUrlRef.current) {
+ URL.revokeObjectURL(pdfBlobUrlRef.current);
+ }
+
+ pdfBlobUrlRef.current = URL.createObjectURL(blob);
+ setPdfBlob(pdfBlobUrlRef.current);
+ } catch (error) {
+ setCompileError(error.message);
+ throw error;
+ } finally {
+ setIsCompiling(false);
+ isCompilingRef.current = false;
+ }
+ }, []);
+
const handleCompileOnly = useCallback(async () => {
if (isCompilingRef.current) return;
@@ -256,13 +295,17 @@ export function useLatex(initialData) {
}
};
- const handleDownloadPDF = async () => {
+ const handleDownloadPDF = async (cheatSheetId = null) => {
setIsLoading(true);
try {
const response = await fetch('/api/compile/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ content }),
+ body: JSON.stringify(
+ cheatSheetId
+ ? { cheat_sheet_id: cheatSheetId }
+ : { content }
+ ),
});
if (!response.ok) throw new Error('Failed to compile LaTeX');
const blob = await response.blob();
@@ -344,6 +387,7 @@ export function useLatex(initialData) {
handleGenerateSheet,
handlePreview,
handleCompileOnly,
+ compileSavedSheet,
handleDownloadPDF,
handleDownloadTex,
clearLatex
diff --git a/frontend/src/hooks/practiceProblems.js b/frontend/src/hooks/practiceProblems.js
new file mode 100644
index 0000000..ee107ea
--- /dev/null
+++ b/frontend/src/hooks/practiceProblems.js
@@ -0,0 +1,259 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+const STORAGE_KEY = 'cheatSheetProblems';
+
+const createClientId = () => `problem-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+
+const loadFromStorage = () => {
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ return saved ? JSON.parse(saved) : null;
+ } catch (error) {
+ console.error('Failed to load practice problems from localStorage', error);
+ return null;
+ }
+};
+
+const saveToStorage = (problems) => {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(problems));
+ } catch (error) {
+ console.error('Failed to save practice problems to localStorage', error);
+ }
+};
+
+const flattenProblemErrors = (errorData = {}) => {
+ const entries = Object.entries(errorData || {});
+
+ if (entries.length === 0) {
+ return ['Failed to save practice problem.'];
+ }
+
+ return entries.flatMap(([field, value]) => {
+ const messages = Array.isArray(value) ? value : [value];
+ return messages.map((message) => {
+ if (field === 'non_field_errors' || field === 'detail' || field === 'error') {
+ return String(message);
+ }
+
+ return `${field}: ${message}`;
+ });
+ });
+};
+
+const normalizeProblem = (problem = {}, index = 0) => ({
+ clientId: problem.clientId || `problem-${problem.id ?? index}-${index}`,
+ id: problem.id ?? null,
+ label: problem.label ?? '',
+ sourceText: problem.sourceText ?? problem.source_text ?? '',
+ sourceFormat: problem.sourceFormat ?? problem.source_format ?? 'simple_v1',
+ compiledLatex: problem.compiledLatex ?? problem.compiled_latex ?? '',
+ order: problem.order ?? index + 1,
+ errors: Array.isArray(problem.errors) ? problem.errors : [],
+});
+
+export function usePracticeProblems(initialData) {
+ const initialProblems = useMemo(() => {
+ const savedProblems = loadFromStorage();
+ if (Array.isArray(savedProblems) && savedProblems.length > 0) {
+ return savedProblems.map((problem, index) => normalizeProblem(problem, index));
+ }
+
+ return (initialData?.practiceProblems || initialData?.problems || []).map((problem, index) =>
+ normalizeProblem(problem, index)
+ );
+ }, [initialData]);
+
+ const [problems, setProblems] = useState(initialProblems);
+ const [removedProblemIds, setRemovedProblemIds] = useState([]);
+
+ useEffect(() => {
+ saveToStorage(problems);
+ }, [problems]);
+
+ const addProblem = useCallback(() => {
+ setProblems((prev) => [
+ ...prev,
+ normalizeProblem(
+ {
+ clientId: createClientId(),
+ label: '',
+ sourceText: '',
+ sourceFormat: 'simple_v1',
+ compiledLatex: '',
+ order: prev.length + 1,
+ },
+ prev.length
+ ),
+ ]);
+ }, []);
+
+ const updateProblem = useCallback((clientId, field, value) => {
+ setProblems((prev) =>
+ prev.map((problem) =>
+ problem.clientId === clientId
+ ? {
+ ...problem,
+ [field]: value,
+ errors: [],
+ }
+ : problem
+ )
+ );
+ }, []);
+
+ const removeProblem = useCallback((clientId) => {
+ setProblems((prev) => {
+ const next = prev.filter((problem) => problem.clientId !== clientId);
+ const removedProblem = prev.find((problem) => problem.clientId === clientId);
+
+ if (removedProblem?.id) {
+ setRemovedProblemIds((current) => [...current, removedProblem.id]);
+ }
+
+ return next.map((problem, index) => ({
+ ...problem,
+ order: index + 1,
+ }));
+ });
+ }, []);
+
+ const reorderProblems = useCallback((oldIndex, newIndex) => {
+ setProblems((prev) => {
+ const next = [...prev];
+ const [moved] = next.splice(oldIndex, 1);
+ next.splice(newIndex, 0, moved);
+
+ return next.map((problem, index) => ({
+ ...problem,
+ order: index + 1,
+ }));
+ });
+ }, []);
+
+ const clearProblems = useCallback(() => {
+ setProblems([]);
+ setRemovedProblemIds([]);
+ localStorage.removeItem(STORAGE_KEY);
+ }, []);
+
+ const serializeProblems = useCallback(
+ () =>
+ problems.map((problem, index) => ({
+ id: problem.id,
+ label: problem.label,
+ source_text: problem.sourceText,
+ source_format: problem.sourceFormat,
+ compiled_latex: problem.compiledLatex,
+ order: index + 1,
+ })),
+ [problems]
+ );
+
+ const syncProblems = useCallback(
+ async (cheatSheetId) => {
+ if (!cheatSheetId) {
+ throw new Error('Save the cheat sheet before saving practice problems.');
+ }
+
+ const nextProblems = problems.map((problem, index) => ({
+ ...problem,
+ order: index + 1,
+ errors: [],
+ }));
+ const originalProblemsById = new Map(
+ problems.filter((problem) => problem.id).map((problem) => [problem.id, normalizeProblem(problem)])
+ );
+ const createdProblemIds = [];
+ const updatedProblemIds = [];
+
+ const rollbackMutations = async () => {
+ await Promise.allSettled([
+ ...createdProblemIds.map((problemId) =>
+ fetch(`/api/problems/${problemId}/`, {
+ method: 'DELETE',
+ })
+ ),
+ ...updatedProblemIds.map((problemId) => {
+ const originalProblem = originalProblemsById.get(problemId);
+ if (!originalProblem) {
+ return Promise.resolve();
+ }
+
+ return fetch(`/api/problems/${problemId}/`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ cheat_sheet: cheatSheetId,
+ label: originalProblem.label,
+ source_text: originalProblem.sourceText,
+ source_format: originalProblem.sourceFormat,
+ order: originalProblem.order,
+ }),
+ });
+ }),
+ ]);
+ };
+
+ for (let index = 0; index < nextProblems.length; index += 1) {
+ const problem = nextProblems[index];
+ const payload = {
+ cheat_sheet: cheatSheetId,
+ label: problem.label,
+ source_text: problem.sourceText,
+ source_format: problem.sourceFormat,
+ order: index + 1,
+ };
+
+ const response = await fetch(problem.id ? `/api/problems/${problem.id}/` : '/api/problems/', {
+ method: problem.id ? 'PATCH' : 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ await rollbackMutations();
+ nextProblems[index] = {
+ ...problem,
+ errors: flattenProblemErrors(errorData),
+ };
+ setProblems(nextProblems);
+ throw new Error(nextProblems[index].errors[0] || 'Failed to save practice problem.');
+ }
+
+ const savedProblem = await response.json();
+ nextProblems[index] = normalizeProblem(savedProblem, index);
+
+ if (problem.id) {
+ updatedProblemIds.push(problem.id);
+ } else if (savedProblem.id) {
+ createdProblemIds.push(savedProblem.id);
+ }
+ }
+
+ for (const removedId of removedProblemIds) {
+ const response = await fetch(`/api/problems/${removedId}/`, { method: 'DELETE' });
+ if (!response.ok && response.status !== 404) {
+ throw new Error('Failed to delete a removed practice problem.');
+ }
+ }
+
+ setProblems(nextProblems);
+ setRemovedProblemIds([]);
+ return nextProblems.map((problem, index) => normalizeProblem(problem, index));
+ },
+ [problems, removedProblemIds]
+ );
+
+ return {
+ problems,
+ addProblem,
+ updateProblem,
+ removeProblem,
+ reorderProblems,
+ clearProblems,
+ serializeProblems,
+ syncProblems,
+ };
+}
From f45be8e964a3b28996ca4076a4a5103932edec65 Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 21:27:32 -0700
Subject: [PATCH 10/15] Style the practice problem editor
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
frontend/src/App.css | 133 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 133 insertions(+)
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 96fd5b6..937b4be 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -692,6 +692,139 @@ label {
padding: var(--space-md) 0;
}
+.practice-problem-section {
+ margin-top: var(--space-lg);
+ padding-top: var(--space-lg);
+ border-top: 1px solid var(--border);
+}
+
+.practice-problem-section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: var(--space-md);
+ margin-bottom: var(--space-md);
+}
+
+.practice-problem-section-label {
+ display: block;
+ font-weight: 700;
+ margin-bottom: var(--space-xs);
+}
+
+.practice-problem-section-copy {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 0.875rem;
+}
+
+.practice-problem-help {
+ margin-bottom: var(--space-md);
+ background: var(--box-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 0.75rem 1rem;
+}
+
+.practice-problem-help summary {
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.practice-problem-help pre {
+ margin: var(--space-sm) 0 0 0;
+ white-space: pre-wrap;
+ color: var(--text-muted);
+ font-size: 0.8125rem;
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+}
+
+.practice-problem-empty {
+ padding: 1rem;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius-md);
+ color: var(--text-muted);
+ background: var(--box-bg);
+}
+
+.practice-problem-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+}
+
+.practice-problem-card {
+ background: var(--box-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ padding: 1rem;
+}
+
+.practice-problem-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: var(--space-md);
+ margin-bottom: var(--space-md);
+}
+
+.practice-problem-card-title {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-sm);
+}
+
+.practice-problem-card-subtitle {
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ margin-top: 0.125rem;
+}
+
+.practice-problem-fields {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+}
+
+.practice-problem-field label {
+ display: block;
+ margin-bottom: var(--space-xs);
+}
+
+.practice-problem-input {
+ margin-bottom: 0;
+}
+
+.practice-problem-textarea {
+ width: 100%;
+ resize: vertical;
+ min-height: 180px;
+ padding: 0.875rem 1rem;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--input-border);
+ background: var(--input-bg);
+ color: var(--input-text);
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+ font-size: 0.875rem;
+ line-height: 1.55;
+}
+
+.practice-problem-textarea:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
+}
+
+.practice-problem-errors {
+ margin-top: var(--space-sm);
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius-md);
+ border: 1px solid rgba(248, 113, 113, 0.24);
+ background: var(--code-error-bg);
+ color: var(--code-error);
+ font-size: 0.8125rem;
+}
+
/* ==========================================================================
LAYOUT OPTIONS
========================================================================== */
From c2d1c57f10710faaa956a48ca2d293952975f8d4 Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 21:49:00 -0700
Subject: [PATCH 11/15] Add a practice problem preview endpoint
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
backend/api/serializers.py | 14 ++++++++++
backend/api/tests.py | 55 ++++++++++++++++++++++++++++++++++++++
backend/api/views.py | 51 ++++++++++++++++++++++++++++++-----
3 files changed, 113 insertions(+), 7 deletions(-)
diff --git a/backend/api/serializers.py b/backend/api/serializers.py
index d72a0d9..21a357f 100644
--- a/backend/api/serializers.py
+++ b/backend/api/serializers.py
@@ -100,6 +100,20 @@ def validate(self, attrs):
return attrs
+class PracticeProblemPreviewSerializer(serializers.Serializer):
+ label = serializers.CharField(required=False, allow_blank=True, default="")
+ source_format = serializers.CharField(required=False, default="simple_v1")
+ source_text = serializers.CharField()
+
+ def validate(self, attrs):
+ if attrs.get("source_format") != "simple_v1":
+ raise serializers.ValidationError(
+ {"source_format": ["Preview currently supports only 'simple_v1'."]}
+ )
+
+ return attrs
+
+
class CheatSheetSerializer(serializers.ModelSerializer):
problems = PracticeProblemSerializer(many=True, read_only=True)
full_latex = serializers.SerializerMethodField()
diff --git a/backend/api/tests.py b/backend/api/tests.py
index 7c4ecf1..d9f4eab 100644
--- a/backend/api/tests.py
+++ b/backend/api/tests.py
@@ -343,6 +343,61 @@ def test_create_simple_v1_problem_returns_line_aware_validation_errors(self, api
assert "source_text" in resp.json()
assert "Line 4" in resp.json()["source_text"][0]
+ def test_preview_simple_v1_problem_compiles_source(self, api_client):
+ resp = api_client.post(
+ "/api/problems/preview/",
+ {
+ "label": "Quadratic factoring",
+ "source_format": "simple_v1",
+ "source_text": (
+ "problem:\n"
+ " text: Solve for x\n"
+ " math: x^2 - 5x + 6 = 0\n\n"
+ "steps:\n"
+ " text: Factor the trinomial\n"
+ " math: x^2 - 5x + 6 = (x - 2)(x - 3)"
+ ),
+ },
+ format="json",
+ )
+
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["is_valid"] is True
+ assert data["errors"] == []
+ assert "\\subsection*{Quadratic factoring}" in data["compiled_latex"]
+
+ def test_preview_simple_v1_problem_returns_line_aware_errors(self, api_client):
+ resp = api_client.post(
+ "/api/problems/preview/",
+ {
+ "label": "Broken block",
+ "source_format": "simple_v1",
+ "source_text": "problem:\n text: Solve for x\n\nanswer:\n math: x = 2",
+ },
+ format="json",
+ )
+
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["is_valid"] is False
+ assert data["compiled_latex"] == ""
+ assert data["errors"][0]["line"] == 4
+ assert "Unknown top-level key 'answer'" in data["errors"][0]["message"]
+
+ def test_preview_rejects_unsupported_source_format(self, api_client):
+ resp = api_client.post(
+ "/api/problems/preview/",
+ {
+ "source_format": "latex_legacy",
+ "source_text": "problem:\n text: Preview me",
+ },
+ format="json",
+ )
+
+ assert resp.status_code == 400
+ assert "source_format" in resp.json()
+
def test_filter_problems_by_sheet(self, api_client, sample_problem, sample_sheet):
resp = api_client.get(f"/api/problems/?cheat_sheet={sample_sheet.id}")
assert resp.status_code == 200
diff --git a/backend/api/views.py b/backend/api/views.py
index 23bd3be..8d5b6ee 100644
--- a/backend/api/views.py
+++ b/backend/api/views.py
@@ -1,14 +1,24 @@
-from rest_framework.decorators import api_view, action
-from rest_framework.response import Response
-from rest_framework import status, viewsets
-from django.http import FileResponse
-from django.shortcuts import get_object_or_404
+from typing import cast
+
+import os
import subprocess
import tempfile
-import os
+
+from django.http import FileResponse
+from django.shortcuts import get_object_or_404
+from rest_framework.decorators import api_view, action
+from rest_framework import status, viewsets
+from rest_framework.response import Response
+
+from api.services.practice_problem_compiler import compile_source
from .models import Template, CheatSheet, PracticeProblem
-from .serializers import TemplateSerializer, CheatSheetSerializer, PracticeProblemSerializer
+from .serializers import (
+ TemplateSerializer,
+ CheatSheetSerializer,
+ PracticeProblemSerializer,
+ PracticeProblemPreviewSerializer,
+)
from .formula_data import get_formula_data, get_classes_with_details, get_special_class_formula, is_special_class
from .latex_utils import build_latex_for_formulas, LATEX_HEADER, LATEX_FOOTER
@@ -237,3 +247,30 @@ def get_queryset(self):
if cheat_sheet_id:
queryset = queryset.filter(cheat_sheet=cheat_sheet_id)
return queryset
+
+ @action(detail=False, methods=['post'], url_path='preview')
+ def preview(self, request):
+ serializer = PracticeProblemPreviewSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ validated_data = cast(dict[str, str], serializer.validated_data)
+ label = validated_data.get("label", "")
+ source_format = validated_data["source_format"]
+ source_text = validated_data["source_text"]
+ result = compile_source(source_text, label=label)
+
+ return Response(
+ {
+ "source_format": source_format,
+ "compiled_latex": result.compiled_latex,
+ "errors": [
+ {
+ "line": error.line,
+ "column": error.column,
+ "message": error.message,
+ }
+ for error in result.errors
+ ],
+ "is_valid": result.is_valid,
+ }
+ )
From ec3203e1d7aa7cb86cd9b4762019f75172e7f59c Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 21:49:00 -0700
Subject: [PATCH 12/15] Preview compiled practice problems while editing
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
frontend/src/App.css | 44 ++++++
frontend/src/components/CreateCheatSheet.jsx | 43 +++++-
frontend/src/hooks/practiceProblems.js | 148 ++++++++++++++++++-
3 files changed, 231 insertions(+), 4 deletions(-)
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 937b4be..7d37066 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -825,6 +825,50 @@ label {
font-size: 0.8125rem;
}
+.practice-problem-preview-card {
+ margin-top: var(--space-md);
+ padding: 0.875rem 1rem;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border);
+ background: rgba(59, 130, 246, 0.05);
+}
+
+.practice-problem-preview-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-sm);
+ margin-bottom: var(--space-sm);
+ font-size: 0.8125rem;
+ font-weight: 600;
+}
+
+.practice-problem-preview-status {
+ color: var(--text-muted);
+ font-weight: 500;
+}
+
+.practice-problem-preview-status.ready {
+ color: #22c55e;
+}
+
+.practice-problem-preview-status.loading {
+ color: var(--primary);
+}
+
+.practice-problem-preview-status.error {
+ color: var(--code-error);
+}
+
+.practice-problem-preview {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: var(--text-muted);
+ font-size: 0.8125rem;
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+}
+
/* ==========================================================================
LAYOUT OPTIONS
========================================================================== */
diff --git a/frontend/src/components/CreateCheatSheet.jsx b/frontend/src/components/CreateCheatSheet.jsx
index 7172316..b63148f 100644
--- a/frontend/src/components/CreateCheatSheet.jsx
+++ b/frontend/src/components/CreateCheatSheet.jsx
@@ -314,12 +314,27 @@ const FormulaSelection = ({
);
-function SortableProblemBlock({ problem, onChange, onRemove }) {
+function SortableProblemBlock({ problem, onChange, onRemove, onPreview, onClearPreview }) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({
id: problem.clientId,
data: { type: 'problem' },
});
+ useEffect(() => {
+ if (!problem.sourceText.trim()) {
+ onClearPreview(problem.clientId);
+ return undefined;
+ }
+
+ const timer = window.setTimeout(() => {
+ onPreview(problem.clientId);
+ }, 500);
+
+ return () => {
+ window.clearTimeout(timer);
+ };
+ }, [problem.clientId, problem.label, problem.sourceText, onPreview, onClearPreview]);
+
const style = {
transform: CSS.Transform.toString(transform),
opacity: isDragging ? 0.8 : 1,
@@ -377,11 +392,29 @@ function SortableProblemBlock({ problem, onChange, onRemove }) {
))}
)}
+
+
+
+ Compiled block preview
+ 0 ? 'error' : problem.isPreviewing ? 'loading' : problem.compiledLatex ? 'ready' : ''}`}>
+ {problem.isPreviewing
+ ? 'Checking syntax...'
+ : problem.errors?.length > 0
+ ? 'Fix errors to preview'
+ : problem.compiledLatex
+ ? problem.isDirty
+ ? 'Preview ready — save to persist'
+ : 'Preview ready'
+ : 'Start typing to preview'}
+
+
+
{problem.compiledLatex || 'No compiled output yet.'}
+
);
}
-function PracticeProblemEditor({ problems, onAddProblem, onChangeProblem, onRemoveProblem, onReorderProblems }) {
+function PracticeProblemEditor({ problems, onAddProblem, onChangeProblem, onRemoveProblem, onReorderProblems, onPreviewProblem, onClearPreview }) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
@@ -431,6 +464,8 @@ function PracticeProblemEditor({ problems, onAddProblem, onChangeProblem, onRemo
problem={problem}
onChange={onChangeProblem}
onRemove={onRemoveProblem}
+ onPreview={onPreviewProblem}
+ onClearPreview={onClearPreview}
/>
))}
@@ -734,6 +769,8 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
removeProblem,
reorderProblems,
clearProblems,
+ clearProblemPreview,
+ previewProblem,
serializeProblems,
syncProblems,
} = usePracticeProblems(initialData);
@@ -866,6 +903,8 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
onChangeProblem={updateProblem}
onRemoveProblem={removeProblem}
onReorderProblems={reorderProblems}
+ onPreviewProblem={previewProblem}
+ onClearPreview={clearProblemPreview}
/>
diff --git a/frontend/src/hooks/practiceProblems.js b/frontend/src/hooks/practiceProblems.js
index ee107ea..c6cb185 100644
--- a/frontend/src/hooks/practiceProblems.js
+++ b/frontend/src/hooks/practiceProblems.js
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
const STORAGE_KEY = 'cheatSheetProblems';
@@ -14,9 +14,19 @@ const loadFromStorage = () => {
}
};
+const serializeProblemsForStorage = (problems) =>
+ problems.map((problem, index) => ({
+ clientId: problem.clientId,
+ id: problem.id,
+ label: problem.label,
+ source_text: problem.sourceText,
+ source_format: problem.sourceFormat,
+ order: problem.order ?? index + 1,
+ }));
+
const saveToStorage = (problems) => {
try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(problems));
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(serializeProblemsForStorage(problems)));
} catch (error) {
console.error('Failed to save practice problems to localStorage', error);
}
@@ -50,6 +60,8 @@ const normalizeProblem = (problem = {}, index = 0) => ({
compiledLatex: problem.compiledLatex ?? problem.compiled_latex ?? '',
order: problem.order ?? index + 1,
errors: Array.isArray(problem.errors) ? problem.errors : [],
+ isPreviewing: Boolean(problem.isPreviewing),
+ isDirty: Boolean(problem.isDirty),
});
export function usePracticeProblems(initialData) {
@@ -66,8 +78,11 @@ export function usePracticeProblems(initialData) {
const [problems, setProblems] = useState(initialProblems);
const [removedProblemIds, setRemovedProblemIds] = useState([]);
+ const problemsRef = useRef(initialProblems);
+ const previewRequestIdsRef = useRef(new Map());
useEffect(() => {
+ problemsRef.current = problems;
saveToStorage(problems);
}, [problems]);
@@ -95,6 +110,7 @@ export function usePracticeProblems(initialData) {
? {
...problem,
[field]: value,
+ isDirty: field === 'label' || field === 'sourceText' ? true : problem.isDirty,
errors: [],
}
: problem
@@ -111,6 +127,8 @@ export function usePracticeProblems(initialData) {
setRemovedProblemIds((current) => [...current, removedProblem.id]);
}
+ previewRequestIdsRef.current.delete(clientId);
+
return next.map((problem, index) => ({
...problem,
order: index + 1,
@@ -134,9 +152,133 @@ export function usePracticeProblems(initialData) {
const clearProblems = useCallback(() => {
setProblems([]);
setRemovedProblemIds([]);
+ previewRequestIdsRef.current.clear();
localStorage.removeItem(STORAGE_KEY);
}, []);
+ const clearProblemPreview = useCallback((clientId) => {
+ previewRequestIdsRef.current.set(clientId, (previewRequestIdsRef.current.get(clientId) || 0) + 1);
+
+ setProblems((prev) =>
+ prev.map((problem) => {
+ if (problem.clientId !== clientId) {
+ return problem;
+ }
+
+ if (!problem.compiledLatex && problem.errors.length === 0 && !problem.isPreviewing) {
+ return problem;
+ }
+
+ return {
+ ...problem,
+ compiledLatex: '',
+ errors: [],
+ isPreviewing: false,
+ };
+ })
+ );
+ }, []);
+
+ const previewProblem = useCallback(async (clientId) => {
+ const currentProblem = problemsRef.current.find((problem) => problem.clientId === clientId);
+
+ if (!currentProblem) {
+ return null;
+ }
+
+ if (!currentProblem.sourceText.trim()) {
+ clearProblemPreview(clientId);
+ return null;
+ }
+
+ const nextRequestId = (previewRequestIdsRef.current.get(clientId) || 0) + 1;
+ previewRequestIdsRef.current.set(clientId, nextRequestId);
+
+ setProblems((prev) =>
+ prev.map((problem) =>
+ problem.clientId === clientId
+ ? {
+ ...problem,
+ isPreviewing: true,
+ errors: [],
+ }
+ : problem
+ )
+ );
+
+ try {
+ const response = await fetch('/api/problems/preview/', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ label: currentProblem.label,
+ source_text: currentProblem.sourceText,
+ source_format: currentProblem.sourceFormat,
+ }),
+ });
+ const data = await response.json().catch(() => ({}));
+
+ if (previewRequestIdsRef.current.get(clientId) !== nextRequestId) {
+ return null;
+ }
+
+ if (!response.ok) {
+ const errors = flattenProblemErrors(data);
+ setProblems((prev) =>
+ prev.map((problem) =>
+ problem.clientId === clientId
+ ? {
+ ...problem,
+ isPreviewing: false,
+ errors,
+ compiledLatex: '',
+ }
+ : problem
+ )
+ );
+ return null;
+ }
+
+ const errors = Array.isArray(data.errors)
+ ? data.errors.map((error) => `Line ${error.line}: ${error.message}`)
+ : [];
+
+ setProblems((prev) =>
+ prev.map((problem) =>
+ problem.clientId === clientId
+ ? {
+ ...problem,
+ isPreviewing: false,
+ errors,
+ compiledLatex: data.compiled_latex || '',
+ isDirty: errors.length > 0 ? problem.isDirty : false,
+ }
+ : problem
+ )
+ );
+
+ return data;
+ } catch {
+ if (previewRequestIdsRef.current.get(clientId) !== nextRequestId) {
+ return null;
+ }
+
+ setProblems((prev) =>
+ prev.map((problem) =>
+ problem.clientId === clientId
+ ? {
+ ...problem,
+ isPreviewing: false,
+ errors: ['Failed to preview practice problem.'],
+ compiledLatex: '',
+ }
+ : problem
+ )
+ );
+ return null;
+ }
+ }, [clearProblemPreview]);
+
const serializeProblems = useCallback(
() =>
problems.map((problem, index) => ({
@@ -253,6 +395,8 @@ export function usePracticeProblems(initialData) {
removeProblem,
reorderProblems,
clearProblems,
+ clearProblemPreview,
+ previewProblem,
serializeProblems,
syncProblems,
};
From 545b39238b13649b911a381cde8553c295f0a8c6 Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 22:08:39 -0700
Subject: [PATCH 13/15] Validate practice problem blocks before saving
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
frontend/src/components/CreateCheatSheet.jsx | 68 +++++++--
frontend/src/hooks/practiceProblems.js | 145 ++++++++++++++-----
2 files changed, 162 insertions(+), 51 deletions(-)
diff --git a/frontend/src/components/CreateCheatSheet.jsx b/frontend/src/components/CreateCheatSheet.jsx
index b63148f..8f66563 100644
--- a/frontend/src/components/CreateCheatSheet.jsx
+++ b/frontend/src/components/CreateCheatSheet.jsx
@@ -314,15 +314,21 @@ const FormulaSelection = ({
);
-function SortableProblemBlock({ problem, onChange, onRemove, onPreview, onClearPreview }) {
+function SortableProblemBlock({ problem, onChange, onRemove, onPreview, onClearPreview, disabled = false }) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({
id: problem.clientId,
data: { type: 'problem' },
});
useEffect(() => {
+ if (disabled) {
+ return undefined;
+ }
+
if (!problem.sourceText.trim()) {
- onClearPreview(problem.clientId);
+ if (!problem.errors?.length) {
+ onClearPreview(problem.clientId);
+ }
return undefined;
}
@@ -333,7 +339,7 @@ function SortableProblemBlock({ problem, onChange, onRemove, onPreview, onClearP
return () => {
window.clearTimeout(timer);
};
- }, [problem.clientId, problem.label, problem.sourceText, onPreview, onClearPreview]);
+ }, [problem.clientId, problem.label, problem.sourceText, problem.errors?.length, onPreview, onClearPreview, disabled]);
const style = {
transform: CSS.Transform.toString(transform),
@@ -347,13 +353,13 @@ function SortableProblemBlock({ problem, onChange, onRemove, onPreview, onClearP
-
⋮⋮
+
⋮⋮
{problem.label?.trim() || 'Untitled problem block'}
Drag to set block order in the saved PDF
-
onRemove(problem.clientId)}>
+ onRemove(problem.clientId)} disabled={disabled}>
Delete
@@ -368,6 +374,7 @@ function SortableProblemBlock({ problem, onChange, onRemove, onPreview, onClearP
onChange={(event) => onChange(problem.clientId, 'label', event.target.value)}
placeholder="Quadratic factoring"
className="input-field practice-problem-input"
+ disabled={disabled}
/>
@@ -381,6 +388,7 @@ function SortableProblemBlock({ problem, onChange, onRemove, onPreview, onClearP
className="practice-problem-textarea"
rows={9}
spellCheck="false"
+ disabled={disabled}
/>
@@ -414,13 +422,17 @@ function SortableProblemBlock({ problem, onChange, onRemove, onPreview, onClearP
);
}
-function PracticeProblemEditor({ problems, onAddProblem, onChangeProblem, onRemoveProblem, onReorderProblems, onPreviewProblem, onClearPreview }) {
+function PracticeProblemEditor({ problems, onAddProblem, onChangeProblem, onRemoveProblem, onReorderProblems, onPreviewProblem, onClearPreview, disabled = false }) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const handleDragEnd = ({ active, over }) => {
+ if (disabled) {
+ return;
+ }
+
if (!over || active.id === over.id) {
return;
}
@@ -442,7 +454,7 @@ function PracticeProblemEditor({ problems, onAddProblem, onChangeProblem, onRemo
Author problems in simple_v1, drag them into order, and save to include them in the compiled PDF preview.
- Add problem block
+ Add problem block
@@ -466,6 +478,7 @@ function PracticeProblemEditor({ problems, onAddProblem, onChangeProblem, onRemo
onRemove={onRemoveProblem}
onPreview={onPreviewProblem}
onClearPreview={onClearPreview}
+ disabled={disabled}
/>
))}
@@ -661,19 +674,19 @@ const PdfPreview = ({ pdfBlob, compileError }) => {
);
};
-const ActionToolbar = ({ handleDownloadTex, handleDownloadPDF, isLoading, isSaving, canDownloadPDF, handleClear }) => (
+const ActionToolbar = ({ handleDownloadTex, handleDownloadPDF, isLoading, isSaving, isSubmitting, canDownloadPDF, handleClear }) => (
- {isSaving ? 'Saving...' : 'Save Progress'}
- Download .tex
+ {isSaving ? 'Saving...' : isSubmitting ? 'Validating...' : 'Save Progress'}
+ Download .tex
{isLoading ? 'Compiling...' : 'Download PDF'}
- Clear
+ Clear
);
@@ -745,6 +758,7 @@ const LayoutOptions = ({ columns, setColumns, fontSize, setFontSize, spacing, se
);
const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) => {
+ const [isSubmittingProblems, setIsSubmittingProblems] = useState(false);
const {
classesData,
selectedClasses,
@@ -771,7 +785,7 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
clearProblems,
clearProblemPreview,
previewProblem,
- serializeProblems,
+ validateProblemsForSave,
syncProblems,
} = usePracticeProblems(initialData);
@@ -817,7 +831,15 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
const handleSave = async (e) => {
e.preventDefault();
+
+ if (isSaving || isSubmittingProblems) {
+ return;
+ }
+
+ setIsSubmittingProblems(true);
+
try {
+ const validatedProblems = await validateProblemsForSave();
const basePayload = {
title,
content,
@@ -826,7 +848,14 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
spacing,
margins,
selectedFormulas: getSelectedFormulasList(),
- practiceProblems: serializeProblems(),
+ practiceProblems: validatedProblems.map((problem, index) => ({
+ id: problem.id,
+ label: problem.label,
+ source_text: problem.sourceText,
+ source_format: problem.sourceFormat,
+ compiled_latex: problem.compiledLatex,
+ order: problem.order ?? index + 1,
+ })),
};
const persistedSheet = await onSave(basePayload, { showFeedback: false });
@@ -850,7 +879,14 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
alert('Progress saved!');
} catch (error) {
console.error('Failed to save practice problems', error);
- alert(`Failed to save progress: ${error.message}`);
+ const message = error?.message || 'Failed to save progress.';
+ const isValidationError = [
+ 'Add problem source or delete incomplete practice problem blocks before saving.',
+ 'Fix practice problem errors before saving.',
+ ].includes(message);
+ alert(isValidationError ? message : `Failed to save progress: ${message}`);
+ } finally {
+ setIsSubmittingProblems(false);
}
};
@@ -905,6 +941,7 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
onReorderProblems={reorderProblems}
onPreviewProblem={previewProblem}
onClearPreview={clearProblemPreview}
+ disabled={isSubmittingProblems || isSaving}
/>
@@ -970,6 +1007,7 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
handleDownloadPDF={() => handleDownloadPDF(initialData?.id)}
isLoading={isLoading}
isSaving={isSaving}
+ isSubmitting={isSubmittingProblems}
canDownloadPDF={Boolean(content || initialData?.id)}
handleClear={handleClear}
/>
diff --git a/frontend/src/hooks/practiceProblems.js b/frontend/src/hooks/practiceProblems.js
index c6cb185..6b70a72 100644
--- a/frontend/src/hooks/practiceProblems.js
+++ b/frontend/src/hooks/practiceProblems.js
@@ -51,6 +51,14 @@ const flattenProblemErrors = (errorData = {}) => {
});
};
+const getSaveValidationErrors = (problem) => {
+ if (!problem.sourceText.trim()) {
+ return ['Add problem source or delete this block before saving.'];
+ }
+
+ return [];
+};
+
const normalizeProblem = (problem = {}, index = 0) => ({
clientId: problem.clientId || `problem-${problem.id ?? index}-${index}`,
id: problem.id ?? null,
@@ -179,6 +187,45 @@ export function usePracticeProblems(initialData) {
);
}, []);
+ const requestProblemPreview = useCallback(async (problem) => {
+ try {
+ const response = await fetch('/api/problems/preview/', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ label: problem.label,
+ source_text: problem.sourceText,
+ source_format: problem.sourceFormat,
+ }),
+ });
+ const data = await response.json().catch(() => ({}));
+
+ if (!response.ok) {
+ return {
+ compiledLatex: '',
+ errors: flattenProblemErrors(data),
+ data: null,
+ };
+ }
+
+ const errors = Array.isArray(data.errors)
+ ? data.errors.map((error) => `Line ${error.line}: ${error.message}`)
+ : [];
+
+ return {
+ compiledLatex: data.compiled_latex || '',
+ errors,
+ data,
+ };
+ } catch {
+ return {
+ compiledLatex: '',
+ errors: ['Failed to preview practice problem.'],
+ data: null,
+ };
+ }
+ }, []);
+
const previewProblem = useCallback(async (clientId) => {
const currentProblem = problemsRef.current.find((problem) => problem.clientId === clientId);
@@ -207,57 +254,27 @@ export function usePracticeProblems(initialData) {
);
try {
- const response = await fetch('/api/problems/preview/', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- label: currentProblem.label,
- source_text: currentProblem.sourceText,
- source_format: currentProblem.sourceFormat,
- }),
- });
- const data = await response.json().catch(() => ({}));
+ const result = await requestProblemPreview(currentProblem);
if (previewRequestIdsRef.current.get(clientId) !== nextRequestId) {
return null;
}
- if (!response.ok) {
- const errors = flattenProblemErrors(data);
- setProblems((prev) =>
- prev.map((problem) =>
- problem.clientId === clientId
- ? {
- ...problem,
- isPreviewing: false,
- errors,
- compiledLatex: '',
- }
- : problem
- )
- );
- return null;
- }
-
- const errors = Array.isArray(data.errors)
- ? data.errors.map((error) => `Line ${error.line}: ${error.message}`)
- : [];
-
setProblems((prev) =>
prev.map((problem) =>
problem.clientId === clientId
? {
...problem,
isPreviewing: false,
- errors,
- compiledLatex: data.compiled_latex || '',
- isDirty: errors.length > 0 ? problem.isDirty : false,
+ errors: result.errors,
+ compiledLatex: result.compiledLatex,
+ isDirty: result.errors.length > 0 ? problem.isDirty : false,
}
: problem
)
);
- return data;
+ return result.data;
} catch {
if (previewRequestIdsRef.current.get(clientId) !== nextRequestId) {
return null;
@@ -277,7 +294,62 @@ export function usePracticeProblems(initialData) {
);
return null;
}
- }, [clearProblemPreview]);
+ }, [clearProblemPreview, requestProblemPreview]);
+
+ const validateProblemsForSave = useCallback(async () => {
+ const orderedProblems = problemsRef.current.map((problem, index) => ({
+ ...problem,
+ order: index + 1,
+ }));
+
+ const problemsWithLocalErrors = orderedProblems.map((problem) => {
+ const errors = getSaveValidationErrors(problem);
+
+ return errors.length > 0
+ ? {
+ ...problem,
+ errors,
+ compiledLatex: '',
+ isPreviewing: false,
+ }
+ : problem;
+ });
+
+ if (problemsWithLocalErrors.some((problem) => problem.errors.length > 0)) {
+ setProblems(problemsWithLocalErrors);
+ throw new Error('Add problem source or delete incomplete practice problem blocks before saving.');
+ }
+
+ const validatedProblems = await Promise.all(
+ orderedProblems.map(async (problem) => {
+ if (!problem.sourceText.trim() || (!problem.isDirty && problem.compiledLatex)) {
+ return problem;
+ }
+
+ const result = await requestProblemPreview(problem);
+
+ return {
+ ...problem,
+ compiledLatex: result.compiledLatex,
+ errors: result.errors,
+ isPreviewing: false,
+ isDirty: result.errors.length > 0 ? problem.isDirty : false,
+ };
+ })
+ );
+
+ setProblems(validatedProblems);
+
+ const blockingProblem = validatedProblems.find(
+ (problem) => problem.errors.length > 0 || (problem.sourceText.trim() && !problem.compiledLatex)
+ );
+
+ if (blockingProblem) {
+ throw new Error('Fix practice problem errors before saving.');
+ }
+
+ return validatedProblems;
+ }, [requestProblemPreview]);
const serializeProblems = useCallback(
() =>
@@ -397,6 +469,7 @@ export function usePracticeProblems(initialData) {
clearProblems,
clearProblemPreview,
previewProblem,
+ validateProblemsForSave,
serializeProblems,
syncProblems,
};
From fee0959e43ee49c56bf336088fc1fb425160bfab Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 22:10:18 -0700
Subject: [PATCH 14/15] Polish practice problem save validation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
frontend/src/App.css | 7 +++++++
frontend/src/components/CreateCheatSheet.jsx | 6 +++++-
frontend/src/hooks/practiceProblems.js | 5 +++++
3 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 7d37066..9f723d3 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -718,6 +718,13 @@ label {
font-size: 0.875rem;
}
+.practice-problem-section-status {
+ margin: var(--space-xs) 0 0 0;
+ color: var(--primary);
+ font-size: 0.8125rem;
+ font-weight: 600;
+}
+
.practice-problem-help {
margin-bottom: var(--space-md);
background: var(--box-bg);
diff --git a/frontend/src/components/CreateCheatSheet.jsx b/frontend/src/components/CreateCheatSheet.jsx
index 8f66563..f1ff950 100644
--- a/frontend/src/components/CreateCheatSheet.jsx
+++ b/frontend/src/components/CreateCheatSheet.jsx
@@ -422,7 +422,7 @@ function SortableProblemBlock({ problem, onChange, onRemove, onPreview, onClearP
);
}
-function PracticeProblemEditor({ problems, onAddProblem, onChangeProblem, onRemoveProblem, onReorderProblems, onPreviewProblem, onClearPreview, disabled = false }) {
+function PracticeProblemEditor({ problems, onAddProblem, onChangeProblem, onRemoveProblem, onReorderProblems, onPreviewProblem, onClearPreview, disabled = false, isValidating = false }) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
@@ -453,6 +453,9 @@ function PracticeProblemEditor({ problems, onAddProblem, onChangeProblem, onRemo
Author problems in simple_v1, drag them into order, and save to include them in the compiled PDF preview.
+ {isValidating && (
+ Validating practice problems before save…
+ )}
Add problem block
@@ -942,6 +945,7 @@ const CreateCheatSheet = ({ onSave, onReset, initialData, isSaving = false }) =>
onPreviewProblem={previewProblem}
onClearPreview={clearProblemPreview}
disabled={isSubmittingProblems || isSaving}
+ isValidating={isSubmittingProblems && !isSaving}
/>
diff --git a/frontend/src/hooks/practiceProblems.js b/frontend/src/hooks/practiceProblems.js
index 6b70a72..23823ab 100644
--- a/frontend/src/hooks/practiceProblems.js
+++ b/frontend/src/hooks/practiceProblems.js
@@ -326,6 +326,11 @@ export function usePracticeProblems(initialData) {
return problem;
}
+ previewRequestIdsRef.current.set(
+ problem.clientId,
+ (previewRequestIdsRef.current.get(problem.clientId) || 0) + 1
+ );
+
const result = await requestProblemPreview(problem);
return {
From b0a2e4f9f1f8cba4adab54695015431e2b55ee3c Mon Sep 17 00:00:00 2001
From: Kamisato
Date: Sun, 19 Apr 2026 22:11:25 -0700
Subject: [PATCH 15/15] Document live practice problem previews
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus
---
README.md | 3 ++-
docs/practice-problem-compiler-mvp.md | 17 ++++++++++++-----
2 files changed, 14 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 8fe2674..092908c 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele
### Database Features
- **Templates**: Save and manage reusable LaTeX templates
- **Cheat Sheets**: Save and load your cheat sheet projects
-- **Practice Problems**: Add practice problems to your sheets
+- **Practice Problems**: Add compiler-backed practice-problem blocks with live validation and compiled previews
## Tech Stack
@@ -90,6 +90,7 @@ A full-stack web application for generating LaTeX-based cheat sheets. Users sele
| GET/PUT/PATCH/DELETE | `/api/cheatsheets/{id}/` | Retrieve/update/delete cheat sheet |
| GET/POST | `/api/problems/` | List/create practice problems |
| GET/PUT/PATCH/DELETE | `/api/problems/{id}/` | Retrieve/update/delete problem |
+| POST | `/api/problems/preview/` | Validate and preview a `simple_v1` practice-problem block |
### Available Formula Classes
diff --git a/docs/practice-problem-compiler-mvp.md b/docs/practice-problem-compiler-mvp.md
index 4ddced5..64a3bc9 100644
--- a/docs/practice-problem-compiler-mvp.md
+++ b/docs/practice-problem-compiler-mvp.md
@@ -34,10 +34,11 @@ Let users author practice problems in a simple indented syntax, keep the origina
1. User creates a practice-problem block.
2. User enters a block label, such as `Quadratic factoring`.
3. User writes source text in the simple syntax.
-4. Frontend saves the block through `/api/problems/`.
-5. Backend validates and compiles the block into LaTeX.
-6. Drag reorder changes block `order`.
-7. Cheat sheet PDF uses compiled block LaTeX in that saved order.
+4. Frontend can request a live preview while the user edits.
+5. Frontend saves the block through `/api/problems/`.
+6. Backend validates and compiles the block into LaTeX.
+7. Drag reorder changes block `order`.
+8. Cheat sheet PDF uses compiled block LaTeX in that saved order.
## Data model changes
@@ -107,7 +108,13 @@ def compile_math_expression(expression: str) -> str:
- store `compiled_latex`
- expose read-only `compiled_latex`
-For MVP, no separate parse-preview endpoint is required.
+The original MVP did not require a separate parse-preview endpoint.
+
+The current implementation now includes `POST /api/problems/preview/` so the frontend can:
+
+- run backend-authoritative validation while the user edits
+- render a compiled block preview before save
+- reuse the same line-aware compiler errors shown during create/update
## Rendering behavior