PYQ clone: builder, CBT exam screen, grading, analytics dashboard

Axum + SQLite full-stack copy of the ZenithPrep PYQ practice app.
Paper builder with unseen-question exclusion, timed CBT screen with
palette and autosave, +4/-1 grading with numerical tolerance, per-question
analysis, history, dashboard with weak-subtopic tracking and coach notes.
This commit is contained in:
dev 2026-10-11 20:06:26 +05:30
commit d3001612b2
17 changed files with 4277 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2067
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

21
Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "zenith-pyq-clone"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["form", "query", "json", "http1", "http2"] }
chrono = { version = "0.4.45", features = ["serde"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.152"
sqlx = { version = "0.9.0", features = ["sqlite", "runtime-tokio", "macros", "chrono", "uuid"] }
tokio = { version = "1.53.2", features = ["full"] }
tower-cookies = "0.11.0"
tower-http = { version = "0.7.1", features = ["fs"] }
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
uuid = { version = "1.28.0", features = ["v4", "serde"] }
[dev-dependencies]
serde_urlencoded = "0.7.1"

58
README.md Normal file
View file

@ -0,0 +1,58 @@
# PYQ clone
A working full-stack copy of the ZenithPrep PYQ practice app: build a paper from
JEE Main style questions, take it on a timed CBT screen with +4 / −1 marking,
then read the analysis and progress dashboard.
Rust + Axum + SQLite. Server-rendered HTML, vanilla JS, no frontend framework.
## Run
cargo run
Then open http://127.0.0.1:8080 and sign in with any name and email.
Environment:
| var | default | meaning |
|------------|--------------------|------------------------------|
| `PYQ_ADDR` | `127.0.0.1:8080` | listen address |
| `PYQ_DB` | `pyq.db` | SQLite file, created if absent |
| `PYQ_BASE` | empty | URL prefix when mounted in a subpath |
## Routes
| route | what it does |
|---------------------------|-----------------------------------------------|
| `GET /` | landing page with live bank counts |
| `GET /student`, `POST /student` | local sign-in, sets an HttpOnly session cookie |
| `GET /logout` | clears the session |
| `GET /build`, `POST /build` | chapter picker and paper generation |
| `POST /attempt/:paper` | starts or resumes an attempt |
| `GET /attempt/:paper` | the CBT screen |
| `POST /attempt/:id/answer` | JSON autosave of one answer |
| `POST /attempt/:id/submit` | grades and closes the paper |
| `GET /result/:attempt` | score, accuracy and per-question analysis |
| `GET /history` | every submitted paper |
| `GET /dashboard` | cross-paper trend, weak subtopics, coach notes |
| `GET /s/:token` | public paper view, never a score |
| `GET /api/attempt/:id/state` | autosaved answers as JSON |
## Marking
+4 for a correct answer, −1 for a wrong one, 0 for a blank. Numerical answers
compare against a 0.1 percent tolerance so `20.01` is accepted for `20`.
## Question selection
A paper is drawn from the chosen chapters, shuffled with a seed derived from the
request, and trimmed to the requested length. With "unseen questions only" the
query excludes anything already answered by that user, so the same question does
not come back by accident.
## Tests
cargo test
Covers the marking rules, numerical tolerance, and the urlencoded form decoding
that shapes the build endpoint.

13
content/privacy.html Normal file
View file

@ -0,0 +1,13 @@
<p class="meta">What is stored, where it lives, and what you can do about it.</p>
<h3>What is stored</h3>
<p class="meta">Your name and email, the papers you build, your answers to each question, how long you spent on each one, and the score of every submitted paper.</p>
<h3>Where it lives</h3>
<p class="meta">Everything is written to a SQLite file on the machine running this service. Nothing is sent to a third party. There is no analytics script, no tracking pixel, and no external font request beyond the Google Fonts stylesheet used for typography.</p>
<h3>What is never collected</h3>
<p class="meta">No contacts, no location, no device identifiers, no advertising profile.</p>
<h3>Who can read it</h3>
<p class="meta">Anyone with access to the database file or to a signed-in browser session. Sessions are held in an HttpOnly cookie scoped to this site.</p>
<h3>Deleting your data</h3>
<p class="meta">Delete your account and every paper, answer and result attached to it by removing your row from the users table. Nothing is retained elsewhere, so that removal is complete.</p>
<h3>Questions bank</h3>
<p class="meta">Question content is study material held in the same database. It is not personal data and is not linked to you beyond the papers you build from it.</p>

13
content/terms.html Normal file
View file

@ -0,0 +1,13 @@
<p class="meta">This is a study tool built as a working copy of an existing practice site. Using it means you accept these terms.</p>
<h3>What the service does</h3>
<p class="meta">It builds practice papers from a bank of JEE Main style questions, runs them on a timed screen with +4 and −1 marking, and keeps your submitted papers so you can review results.</p>
<h3>Your account</h3>
<p class="meta">Sign-in is local to this installation. There is no password: anyone who knows the email can attach papers to that account. Do not use it for anything you would mind losing.</p>
<h3>Question accuracy</h3>
<p class="meta">Questions, answers and solutions are study material and may contain errors. Treat a wrong answer key as a bug, not a fact, and check anything you plan to rely on.</p>
<h3>Acceptable use</h3>
<p class="meta">No scraping at scale, no automated bulk paper generation, no attempts to reach other students' papers. Personal practice only.</p>
<h3>No warranty</h3>
<p class="meta">The service is provided as is, with no promise that it will be available or error free.</p>
<h3>Changes</h3>
<p class="meta">Terms can change as the tool does. The version in this file is the one that applies.</p>

13
run_server.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/bash
# Starts the clone detached so it survives shell teardown. Usage: run_server.sh <port> <db>
set -e
DIR="/home/oiupoyt/my stuff yea/projects/zenith-pyq-clone"
PORT="${1:-8123}"
DB="${2:-/tmp/pyq_run.db}"
cd "$DIR"
rm -f "$DB" "$DB-wal" "$DB-shm"
PYQ_DB="$DB" PYQ_ADDR="127.0.0.1:$PORT" RUST_LOG=info \
setsid nohup ./target/debug/zenith-pyq-clone > "/tmp/pyq_server_$PORT.log" 2>&1 < /dev/null &
echo "started pid=$! port=$PORT db=$DB"
sleep 3
curl -s -o /dev/null -w "health=%{http_code}\n" "http://127.0.0.1:$PORT/"

242
src/analytics.rs Normal file
View file

@ -0,0 +1,242 @@
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use sqlx::{Row, SqlitePool};
use crate::exam::{self, Graded};
#[derive(Clone)]
pub struct Bucket {
pub label: String,
pub right: i32,
pub wrong: i32,
pub skipped: i32,
}
impl Bucket {
pub fn total(&self) -> i32 {
self.right + self.wrong + self.skipped
}
pub fn pct(&self) -> i32 {
if self.total() == 0 { 0 } else { (self.right as f64 / self.total() as f64 * 100.0).round() as i32 }
}
pub fn lost(&self) -> i32 {
self.wrong + self.skipped
}
pub fn status(&self) -> &'static str {
let t = self.total();
if t == 0 {
return "Good";
}
let lost = self.lost() as f64 / t as f64;
if lost >= 0.5 {
"Weak"
} else if lost >= 0.3 {
"Revise"
} else {
"Good"
}
}
}
#[allow(dead_code)]
pub struct Analysis {
pub score: i32,
pub max_score: i32,
pub used_secs: i64,
pub right: i32,
pub wrong: i32,
pub skipped: i32,
pub by_subject: Vec<Bucket>,
pub by_difficulty: Vec<Bucket>,
pub by_topic: Vec<(Bucket, String)>,
pub mistakes: Vec<Graded>,
}
pub fn accuracy(right: i32, wrong: i32) -> i32 {
if right + wrong == 0 { 0 } else { (right as f64 / (right + wrong) as f64 * 100.0).round() as i32 }
}
pub fn analyse(rows: &[Graded], score: i32, max_score: i32, used_secs: i64) -> Analysis {
let (mut right, mut wrong, mut skipped) = (0, 0, 0);
let mut subj: Vec<Bucket> = Vec::new();
let mut diff: Vec<Bucket> = Vec::new();
let mut topic: HashMap<(String, String), Bucket> = HashMap::new();
let mut mistakes: Vec<Graded> = Vec::new();
for r in rows {
let attempted = !r.selected.trim().is_empty();
if attempted && r.correct {
right += 1;
} else if attempted {
wrong += 1;
mistakes.push((*r).clone());
} else {
skipped += 1;
}
for (list, key) in [
(&mut subj, r.q.subject.clone()),
(&mut diff, r.q.difficulty.clone()),
] {
match list.iter_mut().find(|b| b.label == key) {
Some(b) => {
if attempted && r.correct { b.right += 1 } else if attempted { b.wrong += 1 } else { b.skipped += 1 }
}
None => {
let mut b = Bucket { label: key, right: 0, wrong: 0, skipped: 0 };
if attempted && r.correct { b.right = 1 } else if attempted { b.wrong = 1 } else { b.skipped = 1 }
list.push(b);
}
}
}
let key = (r.q.chapter.clone(), r.q.subtopic.clone());
let e = topic.entry(key).or_insert_with(|| Bucket { label: r.q.subtopic.clone(), right: 0, wrong: 0, skipped: 0 });
if attempted && r.correct { e.right += 1 } else if attempted { e.wrong += 1 } else { e.skipped += 1 }
}
subj.sort_by(|a, b| a.label.cmp(&b.label));
diff.sort_by_key(|b| match b.label.as_str() { "Easy" => 0, "Medium" => 1, _ => 2 });
let mut by_topic: Vec<(Bucket, String)> = topic.into_iter().map(|((_, s), b)| (b, s)).collect();
by_topic.sort_by_key(|(b, _)| (-b.lost(), b.label.clone()));
Analysis {
score,
max_score,
used_secs,
right,
wrong,
skipped,
by_subject: subj,
by_difficulty: diff,
by_topic,
mistakes,
}
}
pub struct TestRow {
pub attempt_id: String,
pub started_at: DateTime<Utc>,
pub title: String,
pub score: i32,
pub max_score: i32,
pub used_secs: i64,
pub right: i32,
pub wrong: i32,
}
pub async fn history(pool: &SqlitePool, uid: &str) -> anyhow::Result<Vec<TestRow>> {
let rows = sqlx::query(
"SELECT a.id, a.started_at, a.score, a.max_score, a.used_secs, p.title FROM attempts a
JOIN papers p ON p.id=a.paper_id WHERE a.user_id=? AND a.submitted_at IS NOT NULL ORDER BY a.submitted_at DESC",
)
.bind(uid)
.fetch_all(pool)
.await?;
let mut out = Vec::new();
for r in &rows {
let aid: String = r.get("id");
let (g, right, wrong) = exam::history_counts(pool, &aid).await?;
out.push(TestRow {
attempt_id: aid,
started_at: r.get::<String, _>("started_at").parse().unwrap_or_else(|_| Utc::now()),
title: r.get("title"),
score: r.get("score"),
max_score: r.get("max_score"),
used_secs: r.get("used_secs"),
right,
wrong,
});
let _ = g;
}
Ok(out)
}
#[allow(dead_code)]
pub struct Trend {
pub right: i32,
pub wrong: i32,
pub skipped: i32,
pub seen: i32,
}
pub async fn weakest(pool: &SqlitePool, uid: &str) -> anyhow::Result<Vec<(Bucket, String, String)>> {
let rows = sqlx::query(
"SELECT c.name AS chapter, s.name AS subtopic, q.answer, a.selected, q.qtype
FROM answers a JOIN attempts at ON at.id=a.attempt_id JOIN paper_questions pq ON pq.paper_id=at.paper_id AND pq.question_id=a.question_id
JOIN questions q ON q.id=a.question_id JOIN chapters c ON c.id=q.chapter_id JOIN subtopics s ON s.id=q.subtopic_id
WHERE at.user_id=? AND at.submitted_at IS NOT NULL",
)
.bind(uid)
.fetch_all(pool)
.await?;
let mut m: HashMap<(String, String), Bucket> = HashMap::new();
for r in &rows {
let sel: String = r.get("selected");
let ans: String = r.get("answer");
let qtype: String = r.get("qtype");
let attempted = !sel.trim().is_empty();
let ok = match qtype.as_str() {
"NUM" => exam::numerical_ok(&sel, &ans),
_ => attempted && sel.trim().eq_ignore_ascii_case(ans.trim()),
};
let chapter: String = r.get("chapter");
let subtopic: String = r.get("subtopic");
let e = m.entry((chapter, subtopic.clone())).or_insert_with(|| Bucket { label: subtopic, right: 0, wrong: 0, skipped: 0 });
if attempted && ok { e.right += 1 } else if attempted { e.wrong += 1 } else { e.skipped += 1 }
}
let mut v: Vec<(Bucket, String, String)> = m.into_iter().map(|((c, s), b)| (b, c, s)).collect();
v.sort_by_key(|(b, _, _)| (-b.lost(), b.label.clone()));
v.truncate(8);
Ok(v)
}
pub fn coach_notes(a: &Analysis, history: &[TestRow]) -> Vec<String> {
let mut notes = Vec::new();
if a.score == 0 && a.right == 0 {
notes.push("No question in this paper was answered correctly. Start with the chapters you just revised, one at a time, and use unseen-only so nothing is repeated.".into());
} else {
notes.push(format!(
"You scored {} of {} ({}% correct) with {} wrong and {} skipped in {}.",
a.score,
a.max_score,
accuracy(a.right, a.wrong),
a.wrong,
a.skipped,
fmt_dur(a.used_secs)
));
}
if let Some((w, name)) = a.by_topic.iter().find(|(b, _)| b.lost() > 0) {
notes.push(format!("Largest mark loss: {} ({} of {} questions lost). Revise this before another full paper.", name, w.lost(), w.total()));
}
if let Some(h) = a.by_difficulty.iter().find(|b| b.label == "Hard") {
if h.total() > 0 && h.pct() < 40 {
notes.push(format!("Hard questions: {}% correct. Spend a full sitting on hard-only papers before mixing again.", h.pct()));
}
}
if history.len() >= 2 {
let first = history.last().unwrap();
let last = history.first().unwrap();
if last.max_score > 0 && first.max_score > 0 {
let p1 = first.score as f64 / first.max_score as f64 * 100.0;
let p2 = last.score as f64 / last.max_score as f64 * 100.0;
notes.push(format!(
"Across {} tests your score moved from {}% to {}%. {}",
history.len(),
p1.round() as i32,
p2.round() as i32,
if p2 >= p1 { "The trend is up." } else { "The trend is down, so the recent papers targeted something that needs another pass." }
));
}
}
if a.skipped > a.right {
notes.push(format!("{} questions were left blank against {} answered right. Time management, not knowledge, is costing you marks here.", a.skipped, a.right));
}
notes
}
pub fn fmt_dur(s: i64) -> String {
format!("{}h {:02}m {:02}s", s / 3600, (s % 3600) / 60, s % 60)
}
pub fn fmt_pct(score: i32, max: i32) -> i32 {
if max == 0 { 0 } else { (score as f64 / max as f64 * 100.0).round() as i32 }
}

150
src/auth.rs Normal file
View file

@ -0,0 +1,150 @@
use axum::extract::{Form, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse};
use chrono::Utc;
use serde::Deserialize;
use sqlx::SqlitePool;
use std::sync::Arc;
use crate::views;
pub const COOKIE: &str = "pyq_session";
pub struct Ctx {
pub pool: SqlitePool,
pub base: String,
}
impl Ctx {
/// Returns the signed-in user id and display name, or a redirect to sign-in.
pub async fn require_user(&self, h: &HeaderMap) -> Result<(String, String), axum::response::Response> {
match user_id(&self.pool, h).await {
Some(id) => {
let name: String = sqlx::query_scalar("SELECT name FROM users WHERE id=?")
.bind(&id)
.fetch_one(&self.pool)
.await
.unwrap_or_else(|_| "Student".into());
Ok((id, name))
}
None => Err(redirect(&format!("{}/student", self.base))),
}
}
}
pub async fn user_id(pool: &SqlitePool, h: &HeaderMap) -> Option<String> {
let raw = h.get(COOKIE).and_then(|v| v.to_str().ok()).or_else(|| {
h.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
})?;
let mut token: Option<&str> = None;
for part in raw.split(';') {
if let Some((k, v)) = part.trim().split_once('=')
&& k == COOKIE
{
token = Some(v);
}
}
let token = token?;
tracing::debug!("session token={token}");
match sqlx::query_scalar::<_, String>("SELECT user_id FROM sessions WHERE token=?")
.bind(token)
.fetch_optional(pool)
.await
{
Ok(found) => {
tracing::debug!("session lookup -> {:?}", found);
found
}
Err(e) => {
tracing::error!("session lookup failed: {e}");
None
}
}
}
pub fn redirect(to: &str) -> axum::response::Response {
axum::response::Response::builder()
.status(StatusCode::FOUND)
.header(axum::http::header::LOCATION, to)
.body(axum::body::Body::empty())
.expect("static redirect response")
}
pub fn html_page(body: String, code: StatusCode) -> axum::response::Response {
(code, Html(body)).into_response()
}
pub async fn student_post(State(ctx): State<Arc<Ctx>>, Form(f): Form<StudentForm>) -> axum::response::Response {
let name = f.name.trim();
let email = f.email.trim().to_lowercase();
let next = f
.next
.filter(|n| n.starts_with('/') && !n.contains(".."))
.unwrap_or_else(|| "/build".into());
if name.is_empty() || name.chars().count() > 80 {
return html_page(views::student(&ctx, &next, Some("Enter the name your account shows.")), StatusCode::BAD_REQUEST);
}
if !email.contains('@') || email.len() > 120 {
return html_page(views::student(&ctx, &next, Some("That email address does not look right.")), StatusCode::BAD_REQUEST);
}
let candidate = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO users(id,name,email,created_at) VALUES(?,?,?,?) ON CONFLICT(email) DO UPDATE SET name=excluded.name")
.bind(&candidate)
.bind(name)
.bind(&email)
.bind(Utc::now().to_rfc3339())
.execute(&ctx.pool)
.await
.ok();
let uid: String = sqlx::query_scalar("SELECT id FROM users WHERE email=?")
.bind(&email)
.fetch_one(&ctx.pool)
.await
.unwrap_or(candidate);
let token = uuid::Uuid::new_v4().simple().to_string();
sqlx::query("INSERT INTO sessions(token,user_id,created_at) VALUES(?,?,?)")
.bind(&token)
.bind(&uid)
.bind(Utc::now().to_rfc3339())
.execute(&ctx.pool)
.await
.ok();
let cookie = format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000");
axum::response::Response::builder()
.status(StatusCode::FOUND)
.header(axum::http::header::LOCATION, format!("{}{}", ctx.base, next))
.header(axum::http::header::SET_COOKIE, cookie)
.body(axum::body::Body::empty())
.expect("static redirect response")
}
#[derive(Deserialize)]
pub struct StudentForm {
pub name: String,
pub email: String,
#[serde(default)]
pub next: Option<String>,
}
pub async fn logout(State(ctx): State<Arc<Ctx>>, h: HeaderMap) -> axum::response::Response {
if let Some(uid) = user_id(&ctx.pool, &h).await {
tracing::info!("sign out for {uid}");
}
if let Some(raw) = h.get(COOKIE).and_then(|c| c.to_str().ok())
&& let Some((_, v)) = raw.split_once('=')
{
let token = v.split(';').next().unwrap_or_default();
sqlx::query("DELETE FROM sessions WHERE token=?").bind(token).execute(&ctx.pool).await.ok();
}
let mut r = redirect(&format!("{}/", ctx.base));
r.headers_mut().insert(
axum::http::header::SET_COOKIE,
format!("{COOKIE}=; Path=/; HttpOnly; Max-Age=0").parse().expect("valid cookie"),
);
r
}

117
src/db.rs Normal file
View file

@ -0,0 +1,117 @@
use sqlx::SqlitePool;
pub async fn init_db(pool: &SqlitePool) -> anyhow::Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS chapters(id INTEGER PRIMARY KEY, subject TEXT NOT NULL, name TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS subtopics(id INTEGER PRIMARY KEY, chapter_id INTEGER NOT NULL REFERENCES chapters(id), name TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS questions(id INTEGER PRIMARY KEY, subject TEXT NOT NULL, chapter_id INTEGER NOT NULL, subtopic_id INTEGER NOT NULL, qtype TEXT NOT NULL, stem TEXT NOT NULL, opt_a TEXT, opt_b TEXT, opt_c TEXT, opt_d TEXT, answer TEXT NOT NULL, difficulty TEXT NOT NULL, year INTEGER NOT NULL, shift TEXT NOT NULL, solution TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS papers(id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, duration_min INTEGER NOT NULL, created_at TEXT NOT NULL, share_token TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS paper_questions(paper_id TEXT NOT NULL, question_id INTEGER NOT NULL, ord INTEGER NOT NULL, PRIMARY KEY(paper_id, question_id));
CREATE TABLE IF NOT EXISTS attempts(id TEXT PRIMARY KEY, paper_id TEXT NOT NULL, user_id TEXT NOT NULL, started_at TEXT NOT NULL, submitted_at TEXT, score INTEGER DEFAULT 0, max_score INTEGER DEFAULT 0, used_secs INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS answers(attempt_id TEXT NOT NULL, question_id INTEGER NOT NULL, selected TEXT DEFAULT '', marked_review INTEGER DEFAULT 0, time_secs INTEGER DEFAULT 0, PRIMARY KEY(attempt_id, question_id));
CREATE TABLE IF NOT EXISTS mistake_tags(attempt_id TEXT NOT NULL, question_id INTEGER NOT NULL, tag TEXT NOT NULL, PRIMARY KEY(attempt_id, question_id, tag));
CREATE TABLE IF NOT EXISTS sessions(token TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS seen_questions(user_id TEXT NOT NULL, question_id INTEGER NOT NULL, PRIMARY KEY(user_id, question_id));",
)
.execute(pool)
.await?;
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM questions")
.fetch_one(pool)
.await?;
if n == 0 {
seed(pool).await?;
}
Ok(())
}
struct SeedQ<'a> {
subject: &'a str,
chapter: &'a str,
subtopic: &'a str,
qtype: &'a str,
stem: &'a str,
opts: [&'a str; 4],
answer: &'a str,
difficulty: &'a str,
year: i64,
shift: &'a str,
solution: &'a str,
}
pub async fn seed(pool: &SqlitePool) -> anyhow::Result<()> {
let chapters: Vec<(&str, &str, Vec<&str>)> = vec![
("Physics", "Mechanics", vec!["Kinematics", "Laws of Motion", "Work Energy Power"]),
("Physics", "Electromagnetism", vec!["Electrostatics", "Current Electricity"]),
("Physics", "Optics", vec!["Ray Optics", "Wave Optics"]),
("Chemistry", "Physical Chemistry", vec!["Mole Concept", "Chemical Kinetics"]),
("Chemistry", "Organic Chemistry", vec!["GOC", "Aldehydes and Ketones"]),
("Chemistry", "Inorganic Chemistry", vec!["Periodic Table", "Coordination Compounds"]),
("Maths", "Algebra", vec!["Quadratic Equations", "Matrices and Determinants"]),
("Maths", "Calculus", vec!["Limits and Continuity", "Definite Integration"]),
("Maths", "Geometry", vec!["Straight Lines", "Parabola"]),
];
for (subj, ch, subs) in &chapters {
use sqlx::Row;
let id: i64 = sqlx::query("INSERT INTO chapters(subject,name) VALUES(?,?) RETURNING id")
.bind(subj)
.bind(ch)
.fetch_one(pool)
.await?
.get(0);
for s in subs {
sqlx::query("INSERT INTO subtopics(chapter_id,name) VALUES(?,?)")
.bind(id)
.bind(s)
.execute(pool)
.await?;
}
}
let qs: Vec<SeedQ> = vec![
SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Kinematics",qtype:"MCQ",stem:"A particle starts from rest with constant acceleration 2 m/s2. Distance covered in the 5th second is:",opts:["9 m","8 m","10 m","5 m"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"Sn = u + a(2n-1)/2 = 0 + 2(9)/2 = 9 m."},
SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Kinematics",qtype:"MCQ",stem:"Velocity-time graph is a straight line parallel to time axis. The motion is:",opts:["uniform velocity","uniform acceleration","non-uniform velocity","at rest"],answer:"A",difficulty:"Easy",year:2023,shift:"S2",solution:"Slope zero means acceleration zero, velocity constant."},
SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Laws of Motion",qtype:"MCQ",stem:"A 2 kg block on a frictionless surface is pulled by 10 N. Its acceleration is:",opts:["5 m/s2","20 m/s2","2 m/s2","0.2 m/s2"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"a = F/m = 10/2 = 5 m/s2."},
SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Work Energy Power",qtype:"NUM",stem:"A 1 kg mass drops from 20 m (g=10). Speed just before impact in m/s is:",opts:["","","",""],answer:"20",difficulty:"Medium",year:2022,shift:"S1",solution:"v = sqrt(2gh) = sqrt(400) = 20 m/s."},
SeedQ{subject:"Physics",chapter:"Electromagnetism",subtopic:"Electrostatics",qtype:"MCQ",stem:"Force between two point charges doubles the distance. The force becomes:",opts:["one-fourth","half","double","same"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"Coulomb force scales as 1/r2."},
SeedQ{subject:"Physics",chapter:"Electromagnetism",subtopic:"Current Electricity",qtype:"MCQ",stem:"A wire of resistance R is stretched to double length. New resistance is:",opts:["4R","2R","R/2","R"],answer:"A",difficulty:"Medium",year:2024,shift:"S2",solution:"R = rho l/A; volume constant gives R proportional to l2, so 4R."},
SeedQ{subject:"Physics",chapter:"Optics",subtopic:"Ray Optics",qtype:"MCQ",stem:"Focal length of an equiconvex lens (n=1.5, R=20 cm) is:",opts:["20 cm","40 cm","10 cm","30 cm"],answer:"A",difficulty:"Medium",year:2022,shift:"S2",solution:"1/f = (n-1)(2/R) = 0.5(2/20) = 1/20."},
SeedQ{subject:"Physics",chapter:"Optics",subtopic:"Wave Optics",qtype:"MCQ",stem:"In YDSE, fringe width is proportional to:",opts:["wavelength","1/wavelength","screen distance squared","slit width"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"beta = lambda D/d."},
SeedQ{subject:"Chemistry",chapter:"Physical Chemistry",subtopic:"Mole Concept",qtype:"MCQ",stem:"Moles in 4 g of NaOH (M=40) are:",opts:["0.1","0.4","1","4"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"n = 4/40 = 0.1."},
SeedQ{subject:"Chemistry",chapter:"Physical Chemistry",subtopic:"Chemical Kinetics",qtype:"MCQ",stem:"For a first order reaction, t1/2 = 10 min. Rate constant k is:",opts:["0.0693 min-1","0.693 min-1","10 min-1","1 min-1"],answer:"A",difficulty:"Medium",year:2023,shift:"S2",solution:"k = 0.693/t1/2 = 0.0693 min-1."},
SeedQ{subject:"Chemistry",chapter:"Organic Chemistry",subtopic:"GOC",qtype:"MCQ",stem:"Most stable carbocation among: methyl, ethyl, isopropyl, tert-butyl:",opts:["tert-butyl","methyl","ethyl","isopropyl"],answer:"A",difficulty:"Easy",year:2022,shift:"S1",solution:"Hyperconjugation plus inductive effect: 3 degree most stable."},
SeedQ{subject:"Chemistry",chapter:"Organic Chemistry",subtopic:"Aldehydes and Ketones",qtype:"MCQ",stem:"Tollens reagent gives silver mirror with:",opts:["aldehydes","ketones","alkenes","ethers"],answer:"A",difficulty:"Easy",year:2024,shift:"S2",solution:"Aldehydes reduce Tollens; ketones do not."},
SeedQ{subject:"Chemistry",chapter:"Inorganic Chemistry",subtopic:"Periodic Table",qtype:"MCQ",stem:"Highest first ionisation enthalpy among second period:",opts:["Ne","Li","B","O"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"Noble gas Ne has maximum IE1 in period 2."},
SeedQ{subject:"Chemistry",chapter:"Inorganic Chemistry",subtopic:"Coordination Compounds",qtype:"MCQ",stem:"Oxidation state of Fe in [Fe(CN)6]4- is:",opts:["+2","+3","+4","0"],answer:"A",difficulty:"Medium",year:2024,shift:"S1",solution:"x + 6(-1) = -4 gives x = +2."},
SeedQ{subject:"Maths",chapter:"Algebra",subtopic:"Quadratic Equations",qtype:"MCQ",stem:"Roots of x2 - 5x + 6 = 0 are:",opts:["2, 3","1, 6","-2, -3","5, 1"],answer:"A",difficulty:"Easy",year:2024,shift:"S1",solution:"Factors (x-2)(x-3) = 0."},
SeedQ{subject:"Maths",chapter:"Algebra",subtopic:"Matrices and Determinants",qtype:"NUM",stem:"Determinant of [[2,0],[0,3]] is:",opts:["","","",""],answer:"6",difficulty:"Easy",year:2023,shift:"S1",solution:"Diagonal product 2x3 = 6."},
SeedQ{subject:"Maths",chapter:"Calculus",subtopic:"Limits and Continuity",qtype:"MCQ",stem:"lim x->0 sin(x)/x equals:",opts:["1","0","infinity","-1"],answer:"A",difficulty:"Easy",year:2022,shift:"S2",solution:"Standard limit equals 1."},
SeedQ{subject:"Maths",chapter:"Calculus",subtopic:"Definite Integration",qtype:"MCQ",stem:"Integral 0 to pi of sin(x) dx equals:",opts:["2","0","1","pi"],answer:"A",difficulty:"Medium",year:2024,shift:"S2",solution:"[-cos x]0^pi = 1 + 1 = 2."},
SeedQ{subject:"Maths",chapter:"Geometry",subtopic:"Straight Lines",qtype:"MCQ",stem:"Slope of line 2x + 3y = 6 is:",opts:["-2/3","2/3","3/2","-3/2"],answer:"A",difficulty:"Easy",year:2023,shift:"S2",solution:"y = -2x/3 + 2, slope -2/3."},
SeedQ{subject:"Maths",chapter:"Geometry",subtopic:"Parabola",qtype:"MCQ",stem:"Focus of y2 = 8x is:",opts:["(2,0)","(0,2)","(4,0)","(8,0)"],answer:"A",difficulty:"Medium",year:2024,shift:"S1",solution:"4a = 8 gives a = 2, focus (a,0)."},
SeedQ{subject:"Physics",chapter:"Mechanics",subtopic:"Laws of Motion",qtype:"MCQ",stem:"A car rounds a curve at constant speed. Its acceleration is:",opts:["centripetal, nonzero","zero","tangential only","infinite"],answer:"A",difficulty:"Medium",year:2023,shift:"S2",solution:"Direction changes so velocity changes; centripetal acceleration nonzero."},
SeedQ{subject:"Chemistry",chapter:"Physical Chemistry",subtopic:"Mole Concept",qtype:"NUM",stem:"Molarity of 0.5 mol solute in 2 L solution is (mol/L):",opts:["","","",""],answer:"0.25",difficulty:"Easy",year:2022,shift:"S1",solution:"M = 0.5/2 = 0.25."},
SeedQ{subject:"Maths",chapter:"Algebra",subtopic:"Quadratic Equations",qtype:"MCQ",stem:"Discriminant of x2 + 4x + 4 is:",opts:["0","16","-16","4"],answer:"A",difficulty:"Easy",year:2023,shift:"S1",solution:"D = 16 - 16 = 0, equal roots."},
SeedQ{subject:"Maths",chapter:"Calculus",subtopic:"Definite Integration",qtype:"NUM",stem:"Integral 0 to 1 of 2x dx equals:",opts:["","","",""],answer:"1",difficulty:"Easy",year:2022,shift:"S1",solution:"[x2]0^1 = 1."},
];
use sqlx::Row;
for q in &qs {
let ch_id: i64 = sqlx::query("SELECT id FROM chapters WHERE subject=? AND name=?")
.bind(q.subject)
.bind(q.chapter)
.fetch_one(pool)
.await?
.get(0);
let st_id: i64 = sqlx::query("SELECT id FROM subtopics WHERE chapter_id=? AND name=?")
.bind(ch_id)
.bind(q.subtopic)
.fetch_one(pool)
.await?
.get(0);
sqlx::query("INSERT INTO questions(subject,chapter_id,subtopic_id,qtype,stem,opt_a,opt_b,opt_c,opt_d,answer,difficulty,year,shift,solution) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)")
.bind(q.subject).bind(ch_id).bind(st_id).bind(q.qtype).bind(q.stem)
.bind(q.opts[0]).bind(q.opts[1]).bind(q.opts[2]).bind(q.opts[3])
.bind(q.answer).bind(q.difficulty).bind(q.year).bind(q.shift).bind(q.solution)
.execute(pool).await?;
}
Ok(())
}

311
src/exam.rs Normal file
View file

@ -0,0 +1,311 @@
use std::collections::HashMap;
use chrono::Utc;
use serde::Deserialize;
use sqlx::{Row, SqlitePool};
pub const CORRECT: i32 = 4;
pub const NEGATIVE: i32 = -1;
#[derive(Clone)]
pub struct Question {
pub id: i64,
pub subject: String,
pub chapter: String,
pub subtopic: String,
pub qtype: String,
pub stem: String,
pub opts: [String; 4],
pub answer: String,
pub difficulty: String,
pub year: i64,
pub shift: String,
pub solution: String,
}
pub fn qrow(r: &sqlx::sqlite::SqliteRow) -> Question {
let g = |i: &str| -> String { r.try_get::<Option<String>, _>(i).ok().flatten().unwrap_or_default() };
Question {
id: r.get("id"),
subject: r.get("subject"),
chapter: g("chapter"),
subtopic: g("subtopic"),
qtype: r.get("qtype"),
stem: r.get("stem"),
opts: [g("opt_a"), g("opt_b"), g("opt_c"), g("opt_d")],
answer: r.get("answer"),
difficulty: r.get("difficulty"),
year: r.get("year"),
shift: r.get("shift"),
solution: g("solution"),
}
}
/// `chapters` arrives comma-separated: a urlencoded body with repeated `chapters=1&chapters=4`
/// keys keeps only the last value, so the form posts one list field instead.
#[derive(Deserialize)]
pub struct BuildForm {
#[serde(default)]
pub chapters: String,
pub count: Option<usize>,
pub duration: Option<i64>,
pub unseen_only: Option<String>,
}
impl BuildForm {
fn chapter_ids(&self) -> Vec<i64> {
self.chapters.split(',').filter_map(|c| c.trim().parse().ok()).collect()
}
}
pub async fn build_paper(pool: &SqlitePool, uid: &str, f: &BuildForm) -> anyhow::Result<(String, String)> {
let chapters = f.chapter_ids();
if chapters.is_empty() {
anyhow::bail!("Pick at least one chapter");
}
let count = f.count.unwrap_or(12).clamp(3, 45);
let duration = f.duration.unwrap_or(count as i64 * 2).clamp(5, 180);
let unseen = f.unseen_only.as_deref() == Some("on");
let mut ids: Vec<i64> = Vec::new();
for ch in &chapters {
// sqlx requires a static SQL string for queries that bind parameters; unseen-exclusion is a separate branch.
let rows = if unseen {
sqlx::query(
"SELECT q.id FROM questions q WHERE q.chapter_id=?\n AND q.id NOT IN (SELECT question_id FROM seen_questions WHERE user_id=?)",
)
.bind(ch)
.bind(uid)
.fetch_all(pool)
.await?
} else {
sqlx::query("SELECT q.id FROM questions q WHERE q.chapter_id=?")
.bind(ch)
.fetch_all(pool)
.await?
};
for r in rows {
ids.push(r.get::<i64, _>("id"));
}
}
if ids.is_empty() {
anyhow::bail!("No questions left for that selection. Turn off 'unseen only' to reuse questions.");
}
// Deterministic shuffle keyed on the request, so a reload rebuilds an equivalent paper.
let seed = uid.len() as u64 ^ chapters.iter().sum::<i64>() as u64 ^ count as u64;
shuffle(&mut ids, seed);
ids.truncate(count);
let paper = uuid::Uuid::new_v4().to_string();
let share = uuid::Uuid::new_v4().simple().to_string()[..10].to_string();
let title = format!("Mixed paper · {count} questions");
sqlx::query("INSERT INTO papers(id,user_id,title,duration_min,created_at,share_token) VALUES(?,?,?,?,?,?)")
.bind(&paper)
.bind(uid)
.bind(&title)
.bind(duration)
.bind(Utc::now().to_rfc3339())
.bind(&share)
.execute(pool)
.await?;
for (i, qid) in ids.iter().enumerate() {
sqlx::query("INSERT INTO paper_questions(paper_id,question_id,ord) VALUES(?,?,?)")
.bind(&paper)
.bind(qid)
.bind(i as i64)
.execute(pool)
.await?;
}
Ok((paper, share))
}
fn shuffle(v: &mut Vec<i64>, mut seed: u64) {
if seed == 0 {
seed = 0x9e3779b97f4a7c15;
}
for i in (1..v.len()).rev() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let j = (seed % (i as u64 + 1)) as usize;
v.swap(i, j);
}
}
pub fn numerical_ok(selected: &str, answer: &str) -> bool {
let s: f64 = match selected.trim().parse() {
Ok(v) => v,
Err(_) => return false,
};
let a: f64 = match answer.trim().parse() {
Ok(v) => v,
Err(_) => return false,
};
let tol = a.abs() * 1e-3 + 1e-6;
(s - a).abs() <= tol
}
pub fn is_correct(q: &Question, selected: &str) -> bool {
if selected.trim().is_empty() {
return false;
}
if q.qtype == "NUM" {
numerical_ok(selected, &q.answer)
} else {
selected.trim().eq_ignore_ascii_case(q.answer.trim())
}
}
pub async fn start_attempt(pool: &SqlitePool, uid: &str, paper: &str) -> anyhow::Result<String> {
let existing: Option<String> =
sqlx::query_scalar("SELECT a.id FROM attempts a WHERE a.paper_id=? AND a.user_id=? AND a.submitted_at IS NULL")
.bind(paper)
.bind(uid)
.fetch_optional(pool)
.await?;
if let Some(id) = existing {
return Ok(id);
}
let id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO attempts(id,paper_id,user_id,started_at) VALUES(?,?,?,?)")
.bind(&id)
.bind(paper)
.bind(uid)
.bind(Utc::now().to_rfc3339())
.execute(pool)
.await?;
let qids: Vec<i64> = sqlx::query_scalar("SELECT question_id FROM paper_questions WHERE paper_id=? ORDER BY ord")
.bind(paper)
.fetch_all(pool)
.await?;
for qid in &qids {
sqlx::query("INSERT INTO answers(attempt_id,question_id) VALUES(?,?)")
.bind(&id)
.bind(qid)
.execute(pool)
.await?;
}
Ok(id)
}
#[derive(Deserialize)]
pub struct AnswerIn {
pub question_id: i64,
pub selected: String,
pub marked_review: Option<bool>,
pub time_secs: Option<i64>,
}
#[derive(Deserialize)]
pub struct SaveIn {
pub answers: Vec<AnswerIn>,
}
pub async fn save_answers(pool: &SqlitePool, attempt: &str, uid: &str, body: &SaveIn) -> anyhow::Result<()> {
let ok: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?")
.bind(attempt)
.bind(uid)
.fetch_one(pool)
.await?;
if ok == 0 {
anyhow::bail!("Attempt not found");
}
for a in &body.answers {
sqlx::query("UPDATE answers SET selected=?, marked_review=?, time_secs=? WHERE attempt_id=? AND question_id=?")
.bind(&a.selected)
.bind(a.marked_review.unwrap_or(false) as i64)
.bind(a.time_secs.unwrap_or(0).clamp(0, 7200))
.bind(attempt)
.bind(a.question_id)
.execute(pool)
.await?;
}
Ok(())
}
pub async fn paper_questions(pool: &SqlitePool, paper: &str) -> anyhow::Result<Vec<Question>> {
let rows = sqlx::query(
"SELECT q.*, c.name AS chapter, s.name AS subtopic FROM questions q
JOIN chapters c ON c.id=q.chapter_id JOIN subtopics s ON s.id=q.subtopic_id
JOIN paper_questions pq ON pq.question_id=q.id WHERE pq.paper_id=? ORDER BY pq.ord",
)
.bind(paper)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(qrow).collect())
}
pub async fn answers_map(pool: &SqlitePool, attempt: &str) -> anyhow::Result<HashMap<i64, (String, bool, i64)>> {
let rows = sqlx::query("SELECT question_id, selected, marked_review, time_secs FROM answers WHERE attempt_id=?")
.bind(attempt)
.fetch_all(pool)
.await?;
Ok(rows
.iter()
.map(|r| (r.get::<i64, _>("question_id"), (r.get::<String, _>("selected"), r.get::<i64, _>("marked_review") != 0, r.get::<i64, _>("time_secs"))))
.collect())
}
pub async fn history_counts(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(usize, i32, i32)> {
let (rows, _, _, _) = grade(pool, attempt).await?;
let right = rows.iter().filter(|r| !r.selected.trim().is_empty() && r.correct).count();
let wrong = rows.iter().filter(|r| !r.selected.trim().is_empty() && !r.correct).count();
Ok((rows.len(), right as i32, wrong as i32))
}
#[derive(Clone)]
pub struct Graded {
pub q: Question,
pub selected: String,
pub correct: bool,
pub marks: i32,
pub time_secs: i64,
}
pub async fn grade(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(Vec<Graded>, i32, i32, i64)> {
let paper: String = sqlx::query_scalar("SELECT paper_id FROM attempts WHERE id=?").bind(attempt).fetch_one(pool).await?;
let qs = paper_questions(pool, &paper).await?;
let amap = answers_map(pool, attempt).await?;
let mut out = Vec::new();
let (mut score, mut max, mut used) = (0i32, 0i32, 0i64);
for q in qs {
let (sel, _rev, secs) = amap.get(&q.id).cloned().unwrap_or_default();
let attempted = !sel.trim().is_empty();
let correct = is_correct(&q, &sel);
let marks = if !attempted {
0
} else if correct {
CORRECT
} else {
NEGATIVE
};
score += marks;
max += CORRECT;
used += secs;
out.push(Graded { q, selected: sel, correct, marks, time_secs: secs });
}
Ok((out, score, max, used))
}
pub async fn finalize(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(i32, i32, i64)> {
let (rows, score, max, used) = grade(pool, attempt).await?;
let uid: String = sqlx::query_scalar("SELECT user_id FROM attempts WHERE id=?").bind(attempt).fetch_one(pool).await?;
sqlx::query("UPDATE attempts SET submitted_at=?, score=?, max_score=?, used_secs=? WHERE id=?")
.bind(Utc::now().to_rfc3339())
.bind(score)
.bind(max)
.bind(used)
.bind(attempt)
.execute(pool)
.await?;
for r in &rows {
if !r.selected.trim().is_empty() {
sqlx::query("INSERT OR IGNORE INTO seen_questions(user_id,question_id) VALUES(?,?)")
.bind(&uid)
.bind(r.q.id)
.execute(pool)
.await?;
}
}
Ok((score, max, used))
}

393
src/main.rs Normal file
View file

@ -0,0 +1,393 @@
mod analytics;
mod auth;
mod db;
mod exam;
mod views;
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect};
use axum::routing::{get, post};
use axum::{Form, Json, Router};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
use axum::routing::get_service;
use tower_http::services::ServeDir;
use analytics::{analyse, coach_notes};
use auth::Ctx;
use exam::{BuildForm, SaveIn};
type CtxArc = Arc<Ctx>;
fn hx(body: String, code: StatusCode) -> impl IntoResponse {
(code, Html(body))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_max_level(tracing::Level::DEBUG).init();
let db_path = std::env::var("PYQ_DB").unwrap_or_else(|_| "pyq.db".into());
let base = std::env::var("PYQ_BASE").unwrap_or_else(|_| String::new());
let addr = std::env::var("PYQ_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".into());
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{db_path}?mode=rwc"))?
.create_if_missing(true)
.busy_timeout(std::time::Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(opts)
.await?;
sqlx::query("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;").execute(&pool).await?;
db::init_db(&pool).await?;
let ctx = Arc::new(Ctx { pool, base: base.clone() });
let static_dir = format!("{}/static", std::env::current_dir()?.display());
let app = Router::new()
.route("/", get(home))
.route("/student", get(student_get).post(student_post))
.route("/logout", get(auth::logout))
.route("/terms", get(terms))
.route("/privacy", get(privacy))
.route("/build", get(build_page).post(build_post))
.route("/attempt/{paper}", get(attempt_page).post(start_attempt))
.route("/attempt/{attempt}/answer", post(save_answers))
.route("/attempt/{attempt}/submit", post(submit))
.route("/result/{attempt}", get(result_page))
.route("/history", get(history_page))
.route("/dashboard", get(dashboard_page))
.route("/s/{token}", get(shared_paper))
.route("/api/attempt/{attempt}/state", get(attempt_state))
.route("/api/attempt/{attempt}/submit", post(submit_api))
.nest_service("/static", get_service(ServeDir::new(static_dir)))
.with_state(ctx);
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("pyq clone listening on {addr} (base '{base}')");
axum::serve(listener, app).await?;
Ok(())
}
async fn home(State(ctx): State<CtxArc>) -> impl IntoResponse {
let c: (i64, i64, i64, i64, i64) = sqlx::query_as(
"SELECT (SELECT COUNT(*) FROM questions),(SELECT COUNT(DISTINCT subject||'-'||year||'-'||shift) FROM questions),(SELECT COUNT(*) FROM subtopics),(SELECT COUNT(DISTINCT subject) FROM questions),(SELECT COUNT(*) FROM users)",
)
.fetch_one(&ctx.pool)
.await
.unwrap_or((0, 0, 0, 0, 0));
hx(views::landing(&ctx, c.0, c.1, c.2, c.3, c.4, false), StatusCode::OK)
}
async fn terms(State(ctx): State<CtxArc>) -> impl IntoResponse {
hx(views::legal(&ctx, "Terms of use", TERMS), StatusCode::OK)
}
async fn privacy(State(ctx): State<CtxArc>) -> impl IntoResponse {
hx(views::legal(&ctx, "Privacy policy", PRIVACY), StatusCode::OK)
}
async fn student_get(State(ctx): State<CtxArc>, h: HeaderMap, q: Query<std::collections::HashMap<String, String>>) -> axum::response::Response {
let next = q.get("next").cloned().unwrap_or_else(|| "/build".into());
if auth::user_id(&ctx.pool, &h).await.is_some() {
return Redirect::to(&format!("{}{}", ctx.base, next)).into_response();
}
hx(views::student(&ctx, &next, None), StatusCode::OK).into_response()
}
async fn student_post(State(ctx): State<CtxArc>, Form(f): Form<auth::StudentForm>) -> axum::response::Response {
auth::student_post(State(ctx), axum::Form(f)).await
}
async fn build_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::response::Response {
let (_uid, name) = match ctx.require_user(&h).await {
Ok(v) => v,
Err(r) => return r,
};
use sqlx::Row;
let rows = sqlx::query(
"SELECT c.id, c.subject, c.name, s.name AS sub, (SELECT COUNT(*) FROM questions q WHERE q.chapter_id=c.id) AS n
FROM chapters c JOIN subtopics s ON s.chapter_id=c.id ORDER BY c.subject, c.name, s.name",
)
.fetch_all(&ctx.pool)
.await
.unwrap_or_default();
let mut blocks: Vec<views::SubjectBlock> = Vec::new();
for r in &rows {
let subject: String = r.get("subject");
let id: i64 = r.get("id");
let cname: String = r.get("name");
let sub: String = r.get("sub");
let n: i64 = r.get("n");
if !blocks.iter().any(|b| b.subject == subject) {
blocks.push(views::SubjectBlock { subject: subject.clone(), chapters: Vec::new() });
}
let blk = blocks.iter_mut().find(|b| b.subject == subject).unwrap();
match blk.chapters.iter_mut().find(|c| c.id == id) {
Some(c) => {
c.subtopics.push(sub);
c.count = n;
}
None => blk.chapters.push(views::ChapterBlock { id, name: cname, subtopics: vec![sub], count: n }),
}
}
let available: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM questions").fetch_one(&ctx.pool).await.unwrap_or(0);
let form = BuildForm { chapters: String::new(), count: Some(12), duration: Some(24), unseen_only: Some("on".into()) };
hx(views::build(&ctx, &name, blocks, &form, available, None), StatusCode::OK).into_response()
}
async fn build_post(State(ctx): State<CtxArc>, h: HeaderMap, Form(f): Form<BuildForm>) -> axum::response::Response {
let (uid, _name) = match ctx.require_user(&h).await {
Ok(v) => v,
Err(r) => return r,
};
match exam::build_paper(&ctx.pool, &uid, &f).await {
Ok((paper, _share)) => Redirect::to(&format!("{}/attempt/{paper}", ctx.base)).into_response(),
Err(e) => {
use sqlx::Row;
let rows = sqlx::query(
"SELECT c.id, c.subject, c.name, s.name AS sub, (SELECT COUNT(*) FROM questions q WHERE q.chapter_id=c.id) AS n
FROM chapters c JOIN subtopics s ON s.chapter_id=c.id ORDER BY c.subject, c.name, s.name",
)
.fetch_all(&ctx.pool)
.await
.unwrap_or_default();
let mut blocks: Vec<views::SubjectBlock> = Vec::new();
for r in &rows {
let subject: String = r.get("subject");
let id: i64 = r.get("id");
let cname: String = r.get("name");
let sub: String = r.get("sub");
let n: i64 = r.get("n");
if !blocks.iter().any(|b| b.subject == subject) {
blocks.push(views::SubjectBlock { subject: subject.clone(), chapters: Vec::new() });
}
let blk = blocks.iter_mut().find(|b| b.subject == subject).unwrap();
match blk.chapters.iter_mut().find(|c| c.id == id) {
Some(c) => {
c.subtopics.push(sub);
c.count = n;
}
None => blk.chapters.push(views::ChapterBlock { id, name: cname, subtopics: vec![sub], count: n }),
}
}
let available: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM questions").fetch_one(&ctx.pool).await.unwrap_or(0);
let msg = e.to_string();
hx(views::build(&ctx, &_name, blocks, &f, available, Some(&msg)), StatusCode::OK).into_response()
}
}
}
async fn start_attempt(State(ctx): State<CtxArc>, h: HeaderMap, Path(paper): Path<String>) -> axum::response::Response {
let (uid, _) = match ctx.require_user(&h).await {
Ok(v) => v,
Err(r) => return r,
};
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM papers WHERE id=? AND user_id=?")
.bind(&paper)
.bind(&uid)
.fetch_one(&ctx.pool)
.await
.unwrap_or(0);
if exists == 0 {
return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response();
}
match exam::start_attempt(&ctx.pool, &uid, &paper).await {
Ok(a) => Redirect::to(&format!("{}/attempt/{paper}?run={a}", ctx.base)).into_response(),
Err(e) => {
let m = e.to_string();
hx(views::error(&ctx, 500, &m), StatusCode::INTERNAL_SERVER_ERROR).into_response()
}
}
}
async fn attempt_page(State(ctx): State<CtxArc>, h: HeaderMap, Path(paper): Path<String>, q: Query<std::collections::HashMap<String, String>>) -> axum::response::Response {
let (uid, name) = match ctx.require_user(&h).await {
Ok(v) => v,
Err(r) => return r,
};
let attempt_id = match q.get("run") {
Some(a) => a.clone(),
None => match exam::start_attempt(&ctx.pool, &uid, &paper).await {
Ok(a) => a,
Err(_) => return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response(),
},
};
let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=? AND paper_id=?")
.bind(&attempt_id)
.bind(&uid)
.bind(&paper)
.fetch_one(&ctx.pool)
.await
.unwrap_or(0);
if owned == 0 {
return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response();
}
let questions = match exam::paper_questions(&ctx.pool, &paper).await {
Ok(v) => v,
Err(_) => return hx(views::error(&ctx, 500, ""), StatusCode::INTERNAL_SERVER_ERROR).into_response(),
};
let title: String = sqlx::query_scalar("SELECT title FROM papers WHERE id=?").bind(&paper).fetch_one(&ctx.pool).await.unwrap_or_else(|_| "Paper".into());
let duration: i64 = sqlx::query_scalar("SELECT duration_min FROM papers WHERE id=?").bind(&paper).fetch_one(&ctx.pool).await.unwrap_or(30);
let amap = exam::answers_map(&ctx.pool, &attempt_id).await.unwrap_or_default();
let answers: Vec<(i64, String, bool)> = questions
.iter()
.map(|q| {
let (sel, rev, _) = amap.get(&q.id).cloned().unwrap_or_default();
(q.id, sel, rev)
})
.collect();
hx(views::attempt(&ctx, &name, &attempt_id, &questions, &answers, duration, &title), StatusCode::OK).into_response()
}
async fn save_answers(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Path<String>, Json(body): Json<SaveIn>) -> impl IntoResponse {
let uid = match auth::user_id(&ctx.pool, &h).await {
Some(u) => u,
None => return hx("{\"error\":\"sign in first\"}".into(), StatusCode::UNAUTHORIZED),
};
match exam::save_answers(&ctx.pool, &attempt, &uid, &body).await {
Ok(_) => hx("{\"ok\":true}".into(), StatusCode::OK),
Err(e) => {
let m = e.to_string();
hx(format!("{{\"error\":\"{m}\"}}"), StatusCode::NOT_FOUND)
}
}
}
async fn attempt_state(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Path<String>) -> impl IntoResponse {
let uid = match auth::user_id(&ctx.pool, &h).await {
Some(u) => u,
None => return hx("{\"error\":\"sign in first\"}".into(), StatusCode::UNAUTHORIZED),
};
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?")
.bind(&attempt)
.bind(&uid)
.fetch_one(&ctx.pool)
.await
.unwrap_or(0);
if n == 0 {
return hx("{\"error\":\"not found\"}".into(), StatusCode::NOT_FOUND);
}
let amap = exam::answers_map(&ctx.pool, &attempt).await.unwrap_or_default();
let items: Vec<serde_json::Value> = amap
.iter()
.map(|(qid, (sel, rev, secs))| serde_json::json!({"question_id": qid, "selected": sel, "marked_review": rev, "time_secs": secs}))
.collect();
hx(serde_json::json!({"attempt": attempt, "answers": items}).to_string(), StatusCode::OK)
}
async fn submit(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Path<String>) -> axum::response::Response {
let (uid, _) = match ctx.require_user(&h).await {
Ok(v) => v,
Err(r) => return r,
};
let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?").bind(&attempt).bind(&uid).fetch_one(&ctx.pool).await.unwrap_or(0);
if owned == 0 {
return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response();
}
match exam::finalize(&ctx.pool, &attempt).await {
Ok(_) => Redirect::to(&format!("{}/result/{attempt}", ctx.base)).into_response(),
Err(e) => {
let m = e.to_string();
hx(views::error(&ctx, 500, &m), StatusCode::INTERNAL_SERVER_ERROR).into_response()
}
}
}
async fn submit_api(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Path<String>) -> impl IntoResponse {
let uid = match auth::user_id(&ctx.pool, &h).await {
Some(u) => u,
None => return hx("{\"error\":\"sign in first\"}".into(), StatusCode::UNAUTHORIZED),
};
let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=?").bind(&attempt).bind(&uid).fetch_one(&ctx.pool).await.unwrap_or(0);
if owned == 0 {
return hx("{\"error\":\"not found\"}".into(), StatusCode::NOT_FOUND);
}
match exam::finalize(&ctx.pool, &attempt).await {
Ok((score, max, used)) => hx(serde_json::json!({"score": score, "max": max, "used_secs": used, "result_url": format!("{}/result/{attempt}", ctx.base)}).to_string(), StatusCode::OK),
Err(e) => {
let m = e.to_string();
hx(format!("{{\"error\":\"{m}\"}}"), StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
async fn result_page(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Path<String>) -> axum::response::Response {
let (uid, name) = match ctx.require_user(&h).await {
Ok(v) => v,
Err(r) => return r,
};
let owned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM attempts WHERE id=? AND user_id=? AND submitted_at IS NOT NULL").bind(&attempt).bind(&uid).fetch_one(&ctx.pool).await.unwrap_or(0);
if owned == 0 {
return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response();
}
let (rows, score, max, used) = match exam::grade(&ctx.pool, &attempt).await {
Ok(v) => v,
Err(e) => {
let m = e.to_string();
return hx(views::error(&ctx, 500, &m), StatusCode::INTERNAL_SERVER_ERROR).into_response();
}
};
let a = analyse(&rows, score, max, used);
let rv: Vec<views::RowView> = rows
.iter()
.map(|r| views::RowView { q: &r.q, selected: &r.selected, marks: r.marks, correct: r.correct, time_secs: r.time_secs })
.collect();
hx(views::result(&ctx, &name, &attempt, &a, &rv), StatusCode::OK).into_response()
}
async fn history_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::response::Response {
let (uid, name) = match ctx.require_user(&h).await {
Ok(v) => v,
Err(r) => return r,
};
let tests = analytics::history(&ctx.pool, &uid).await.unwrap_or_default();
hx(views::history(&ctx, &name, &tests), StatusCode::OK).into_response()
}
async fn dashboard_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::response::Response {
let (uid, name) = match ctx.require_user(&h).await {
Ok(v) => v,
Err(r) => return r,
};
let tests = analytics::history(&ctx.pool, &uid).await.unwrap_or_default();
let weak = analytics::weakest(&ctx.pool, &uid).await.unwrap_or_default();
let marks: i32 = tests.iter().map(|t| t.score).sum();
let gained: i32 = tests.iter().map(|t| t.right).sum();
let lost: i32 = tests.iter().map(|t| t.wrong).sum();
let notes = coach_notes(
&analytics::Analysis {
score: tests.first().map(|t| t.score).unwrap_or(0),
max_score: tests.first().map(|t| t.max_score).unwrap_or(0),
used_secs: tests.first().map(|t| t.used_secs).unwrap_or(0),
right: tests.first().map(|t| t.right).unwrap_or(0),
wrong: tests.first().map(|t| t.wrong).unwrap_or(0),
skipped: 0,
by_subject: vec![],
by_difficulty: vec![],
by_topic: vec![],
mistakes: vec![],
},
&tests,
);
hx(views::dashboard(&ctx, &name, &tests, &weak, &notes, (tests.len() as i32, marks, gained, lost)), StatusCode::OK).into_response()
}
async fn shared_paper(State(ctx): State<CtxArc>, Path(token): Path<String>) -> axum::response::Response {
let row = sqlx::query("SELECT id, title, duration_min FROM papers WHERE share_token=?").bind(&token).fetch_optional(&ctx.pool).await;
let (id, title, duration) = match row {
Ok(Some(r)) => {
use sqlx::Row;
(r.get::<String, _>("id"), r.get::<String, _>("title"), r.get::<i64, _>("duration_min"))
}
_ => return hx(views::error(&ctx, 404, ""), StatusCode::NOT_FOUND).into_response(),
};
let questions = match exam::paper_questions(&ctx.pool, &id).await {
Ok(v) => v,
Err(_) => return hx(views::error(&ctx, 500, ""), StatusCode::INTERNAL_SERVER_ERROR).into_response(),
};
hx(views::shared(&ctx, &title, duration, &questions), StatusCode::OK).into_response()
}
const TERMS: &str = include_str!("../content/terms.html");
const PRIVACY: &str = include_str!("../content/privacy.html");

563
src/views.rs Normal file
View file

@ -0,0 +1,563 @@
use crate::analytics::{accuracy, fmt_dur, fmt_pct, Analysis, Bucket, TestRow};
use crate::auth::Ctx;
use crate::exam::{BuildForm, Question};
pub fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len() + 16);
for c in s.chars() {
match c {
'&' => o.push_str("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\'' => o.push_str("&#39;"),
_ => o.push(c),
}
}
o
}
pub struct ChapterBlock {
pub id: i64,
pub name: String,
pub subtopics: Vec<String>,
pub count: i64,
}
pub struct SubjectBlock {
pub subject: String,
pub chapters: Vec<ChapterBlock>,
}
fn head(ctx: &Ctx, title: &str) -> String {
format!(
r#"<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{t} · PYQ</title><link rel="stylesheet" href="{b}/static/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head><body>"#,
t = esc(title),
b = ctx.base
)
}
fn nav(ctx: &Ctx, user: Option<&str>) -> String {
let brand = format!(
r#"<header class="topbar"><a class="brand" href="{b}/">PYQ<span>jee main practice</span></a><nav class="nav">"#,
b = ctx.base
);
let links = match user {
Some(_) => format!(
r#"<a class="hidem" href="{b}/build">build</a><a class="hidem" href="{b}/dashboard">dashboard</a><a class="hidem" href="{b}/history">history</a><a class="btn" href="{b}/logout">sign out</a>"#,
b = ctx.base
),
None => format!(
r#"<a class="hidem" href="{b}/terms">terms</a><a class="hidem" href="{b}/privacy">privacy</a><a class="btn primary" href="{b}/build">start a paper</a>"#,
b = ctx.base
),
};
format!("{brand}{links}</nav></header>")
}
fn foot(ctx: &Ctx) -> String {
format!(
r#"<footer><span>PYQ clone · running on sqlite · built as a working copy of zenithprepare.com/PYQ</span><span><a href="{b}/terms">terms</a> · <a href="{b}/privacy">privacy</a></span></footer></body></html>"#,
b = ctx.base
)
}
pub fn shell(ctx: &Ctx, user: Option<&str>, title: &str, body: String) -> String {
format!("{}{}{}{}", head(ctx, title), nav(ctx, user), body, foot(ctx))
}
pub fn landing(ctx: &Ctx, q: i64, shifts: i64, subtopics: i64, subjects: i64, students: i64, signed: bool) -> String {
let cta = if signed {
format!(r#"<a class="btn primary" href="{b}/build">build a paper</a>"#, b = ctx.base)
} else {
format!(r#"<a class="btn primary" href="{b}/build">start a paper</a>"#, b = ctx.base)
};
let body = format!(
r#"<h1>JEE Main practice, built like the real exam screen.</h1>
<p class="meta">Pick the chapters you just revised. Take the paper on a timed computer-based screen with the +4 / −1 marking scheme. Get the analysis the moment you submit.</p>
<div class="kv" style="margin-top:22px">
<div class="card"><span class="dim">questions</span><b>{q}</b></div>
<div class="card"><span class="dim">shifts</span><b>{shifts}</b></div>
<div class="card"><span class="dim">subtopics</span><b>{subtopics}</b></div>
<div class="card"><span class="dim">students</span><b>{students}</b></div>
</div>
<div class="grid2" style="margin-top:18px">
<div class="card"><h3>Build</h3><p class="meta">Choose any mix of chapters across {subjects} subjects. Questions are drawn from real shift data and, with unseen-only on, you never repeat one by accident.</p></div>
<div class="card"><h3>Attempt</h3><p class="meta">Subject tabs, a colour-coded question palette, mark for review, numerical answers, a countdown timer, and +4 for a correct answer with −1 for a wrong one.</p></div>
<div class="card"><h3>Analysis</h3><p class="meta">Score, accuracy, time per question, and results broken down by subject, chapter and difficulty. Solutions sit under every question.</p></div>
<div class="card"><h3>Progress</h3><p class="meta">Every submitted paper is kept. The dashboard tracks percentage and accuracy across tests and names the subtopics costing you the most marks.</p></div>
</div>
<p style="margin-top:20px">{cta}</p>"#,
q = q,
shifts = shifts,
subtopics = subtopics,
subjects = subjects,
students = students,
cta = cta
);
shell(ctx, None, "Home", body)
}
pub fn student(ctx: &Ctx, next: &str, err: Option<&str>) -> String {
let e = err.map(|m| format!(r#"<p class="bad-t">{m}</p>"#)).unwrap_or_default();
let body = format!(
r#"<h1>Sign in</h1>
<p class="meta">This clone runs a local sign-in so you can use the whole flow without a Google account. Enter any name and email; your papers stay in this database.</p>
<form method="post" action="{b}/student" style="max-width:420px;margin-top:18px">
<input type="hidden" name="next" value="{next}">
<label for="name">name</label><input id="name" name="name" type="text" autocomplete="name" required>
<label for="email">email</label><input id="email" name="email" type="email" autocomplete="email" required>
{e}
<div style="margin-top:14px"><button class="btn primary" type="submit">sign in</button></div>
</form>"#,
b = ctx.base,
next = esc(next),
e = e
);
shell(ctx, None, "Sign in", body)
}
pub fn legal(ctx: &Ctx, title: &str, body_md: &str) -> String {
let body = format!(r#"<h1>{t}</h1><div style="max-width:70ch">{b}</div>"#, t = esc(title), b = body_md);
shell(ctx, None, title, body)
}
pub fn error(ctx: &Ctx, code: u16, detail: &str) -> String {
let (t, d) = match code {
404 => ("Nothing at this address".to_string(), "The link may be wrong, or the paper was never created.".to_string()),
_ => ("Something broke on our side".to_string(), detail.to_string()),
};
let body = format!(
r#"<h1>{t}</h1><p class="meta">{d}</p><p style="margin-top:16px"><a class="btn" href="{b}/">back to home</a></p>"#,
t = esc(&t),
d = esc(&d),
b = ctx.base
);
shell(ctx, None, &t, body)
}
pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm, available: i64, err: Option<&str>) -> String {
let mut picker = String::new();
for sb in &blocks {
picker.push_str(&format!("<h3>{}</h3>", esc(&sb.subject)));
for ch in &sb.chapters {
let subs = ch.subtopics.iter().map(|s| esc(s)).collect::<Vec<_>>().join(", ");
picker.push_str(&format!(
r#"<label class="chk"><input type="checkbox" class="chap" value="{cid}"><span><b>{cname}</b><br><span class="dim">{subs} · {count} questions</span></span></label>"#,
cid = ch.id,
cname = esc(&ch.name),
subs = subs,
count = ch.count
));
}
}
let e = err.map(|m| format!(r#"<p class="bad-t">{m}</p>"#)).unwrap_or_default();
let body = format!(
r#"<h1>Build a paper</h1>
<p class="meta">{available} questions match this bank. Pick the chapters you are revising; the paper is drawn from them and marked on the +4 / −1 scheme.</p>
<form method="post" action="{b}/build" style="margin-top:18px">
<input type="hidden" name="chapters" id="chapters" value="">
<div class="grid2"><div>{picker}</div>
<div>
<div style="position:sticky;top:64px">
<h2>paper settings</h2>
<label for="count">questions</label>
<select id="count" name="count">
<option value="9">9 · short drill</option><option value="12" selected>12</option>
<option value="18">18</option><option value="25">25</option><option value="36">36 · full paper</option>
</select>
<label for="duration">duration in minutes</label>
<input id="duration" name="duration" type="number" value="24" min="5" max="180">
<label class="chk" style="margin-top:14px"><input type="checkbox" name="unseen_only" checked><span>unseen questions only<br><span class="dim">skips anything you have already answered once</span></span></label>
<p class="dim" id="pick-note" style="margin-top:10px">no chapters picked yet</p>
{e}
<div style="margin-top:14px"><button class="btn primary" type="submit">create paper</button></div>
</div>
</div></div>
</form>
<script>// Repeated checkbox keys do not survive a urlencoded body, so the ids are joined into one field.
(() => {{
const boxes = [...document.querySelectorAll('.chap')];
const field = document.getElementById('chapters');
const note = document.getElementById('pick-note');
const sync = () => {{
field.value = boxes.filter(b => b.checked).map(b => b.value).join(',');
note.textContent = field.value ? field.value.split(',').length + ' chapters picked' : 'no chapters picked yet';
}};
boxes.forEach(b => b.addEventListener('change', sync));
sync();
}})();</script>"#,
available = available,
b = ctx.base,
picker = picker,
e = e
);
shell(ctx, Some(user), "Build", body)
}
fn palette(questions: &[Question], answers: &[(i64, String, bool)]) -> String {
questions
.iter()
.enumerate()
.map(|(i, q)| {
let a = answers.iter().find(|(id, _, _)| *id == q.id);
let sel = a.map(|(_, s, _)| !s.trim().is_empty()).unwrap_or(false);
let rev = a.map(|(_, _, r)| *r).unwrap_or(false);
let cls = match (sel, rev) {
(true, true) => "pal rev",
(true, false) => "pal ans",
_ => "pal",
};
format!(r#"<button type="button" class="{cls}" data-jump="{i}">{n}</button>"#, cls = cls, n = i + 1)
})
.collect::<Vec<_>>()
.join("")
}
pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], answers: &[(i64, String, bool)], duration_min: i64, title: &str) -> String {
let body_questions: Vec<String> = questions
.iter()
.enumerate()
.map(|(i, q)| {
let cur = answers.iter().find(|(id, _, _)| *id == q.id);
let sel = cur.map(|(_, s, _)| s.clone()).unwrap_or_default();
let rev = cur.map(|(_, _, r)| *r).unwrap_or(false);
let opts: Vec<String> = if q.qtype == "NUM" {
vec![format!(
r#"<input type="text" inputmode="decimal" name="num-{id}" value="{v}" placeholder="numeric answer">"#,
id = q.id,
v = esc(&sel)
)]
} else {
["A", "B", "C", "D"]
.iter()
.enumerate()
.map(|(k, letter)| {
let text = q.opts[k].clone();
let checked = if sel.trim().eq_ignore_ascii_case(letter) { " checked" } else { "" };
format!(
r#"<label class="opt{cls}"><input type="radio" name="q-{id}" value="{letter}"{checked}><span><b>{letter}</b> &nbsp;{t}</span></label>"#,
cls = if sel.trim().eq_ignore_ascii_case(letter) { " sel" } else { "" },
id = q.id,
letter = letter,
checked = checked,
t = esc(&text)
)
})
.collect()
};
format!(
r#"<section class="card qpane" id="qpane-{i}" data-i="{i}"{hidden}>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
<span class="dim">question {n} · {subj} · {ch}</span>
<span class="dim">{qt} · +4 / −1{revflag}</span>
</div>
<p style="margin:10px 0 4px;color:#fff">{stem}</p>
<div class="dim" style="margin-bottom:8px">{sub2} · {year} {shift} · {difficulty}</div>
{opts}
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
<button type="button" class="btn ok" data-act="save">save &amp; next</button>
<button type="button" class="btn" data-act="review">mark for review</button>
<button type="button" class="btn" data-act="clear">clear</button>
</div>
</section>"#,
hidden = if i == 0 { "" } else { " hidden" },
n = i + 1,
subj = esc(&q.subject),
ch = esc(&q.chapter),
qt = esc(&q.qtype),
revflag = if rev { " · <span class=\"warn-t\">marked</span>" } else { "" },
stem = esc(&q.stem),
sub2 = esc(&q.subtopic),
year = q.year,
shift = esc(&q.shift),
difficulty = esc(&q.difficulty),
opts = opts.join("")
)
})
.collect();
let body = format!(
r#"<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin:14px 0">
<div><h1 style="margin:0">{title}</h1><span class="dim">{n} questions · +4 / −1</span></div>
<div style="text-align:right"><div class="timer" id="timer" data-secs="{secs}">{dur}</div><div class="dim">remaining</div></div>
</div>
<form method="post" action="{b}/attempt/{a}/submit" id="exam-form" data-attempt="{a}">
<div class="cbt">
<div>{qs}</div>
<div><h2>palette</h2><div class="palette" id="pal">{pal}</div>
<p class="dim" style="margin-top:10px"><span class="ok-t">answered</span> · <span class="warn-t">review</span> · <span class="dim">blank</span></p>
<button class="btn primary" type="submit" style="width:100%;margin-top:12px">submit paper</button>
</div>
</div></form>
<script src="{b}/static/exam.js"></script>"#,
title = esc(title),
n = questions.len(),
dur = fmt_dur(duration_min as i64 * 60),
secs = duration_min * 60,
b = ctx.base,
a = esc(attempt_id),
qs = body_questions.join(""),
pal = palette(questions, answers)
);
shell(ctx, Some(user), "Test", body)
}
pub struct RowView<'a> {
pub q: &'a Question,
pub selected: &'a str,
pub marks: i32,
pub correct: bool,
pub time_secs: i64,
}
fn seg(b: &Bucket) -> String {
let t = b.total().max(1) as f64;
format!(
r#"<div class="seg"><i style="width:{}%;background:#10b981"></i><i style="width:{}%;background:#ff5555"></i><i style="width:{}%;background:#333"></i></div>"#,
b.right as f64 / t * 100.0,
b.wrong as f64 / t * 100.0,
b.skipped as f64 / t * 100.0
)
}
fn bucket_rows(items: &[Bucket]) -> String {
items
.iter()
.map(|b| {
format!(
r#"<tr><td>{l}</td><td>{r}</td><td>{w}</td><td>{s}</td><td style="width:38%">{seg}</td><td>{p}%</td><td class="{st}">{st}</td></tr>"#,
l = esc(&b.label),
r = b.right,
w = b.wrong,
s = b.skipped,
seg = seg(b),
p = b.pct(),
st = b.status()
)
})
.collect()
}
pub fn result(ctx: &Ctx, user: &str, _attempt_id: &str, a: &Analysis, rows: &[RowView]) -> String {
let detail: String = rows
.iter()
.enumerate()
.map(|(i, r)| {
let state = if r.correct { "correct" } else if r.selected.trim().is_empty() { "blank" } else { "wrong" };
let cls = match state { "correct" => "ok-t", "wrong" => "bad-t", _ => "dim" };
let opts: String = if r.q.qtype == "NUM" {
format!(r#"<p class="dim">your answer: {sel} · correct: {ans}</p>"#, sel = esc(if r.selected.is_empty() { "not attempted" } else { r.selected }), ans = esc(&r.q.answer))
} else {
["A", "B", "C", "D"]
.iter()
.enumerate()
.map(|(k, l)| {
let is_ans = r.q.answer.trim().eq_ignore_ascii_case(l);
let is_sel = r.selected.trim().eq_ignore_ascii_case(l);
let style = if is_ans { "ok-t" } else if is_sel { "bad-t" } else { "dim" };
format!(r#"<span style="margin-right:10px" class="{style}">{l}: {t}{mark}</span>"#, style = style, l = l, t = esc(&r.q.opts[k]), mark = if is_ans { " (correct)" } else if is_sel { " (you)" } else { "" })
})
.collect()
};
format!(
r#"<details class="card" style="margin-bottom:8px"><summary style="cursor:pointer">q{n} · {subj} · <span class="{cls}">{state}</span> · {marks:+} marks</summary>
<p style="margin:12px 0 6px;color:#fff">{stem}</p>
<p style="margin-bottom:8px">{opts}</p>
<p class="dim" style="margin-bottom:8px">{sub2} · {ch} · {year} {shift} · {difficulty} · {qtime}</p>
<p style="color:#38bdf8">{sol}</p></details>"#,
n = i + 1,
subj = esc(&r.q.subject),
cls = cls,
state = state,
marks = r.marks,
stem = esc(&r.q.stem),
opts = opts,
sub2 = esc(&r.q.subtopic),
ch = esc(&r.q.chapter),
year = r.q.year,
shift = esc(&r.q.shift),
difficulty = esc(&r.q.difficulty),
qtime = if r.time_secs > 0 { format!("{}s spent", r.time_secs) } else { "no time recorded".to_string() },
sol = esc(&r.q.solution)
)
})
.collect();
let topic_buckets: Vec<Bucket> = a.by_topic.iter().map(|(b, _)| b.to_owned()).collect();
let body = format!(
r#"<h1>Result</h1>
<div class="kv">
<div class="card"><span class="dim">score</span><b>{score} <span class="dim">/ {max}</span></b></div>
<div class="card"><span class="dim">percentage</span><b>{pct}%</b></div>
<div class="card"><span class="dim">accuracy</span><b class="ok-t">{acc}%</b></div>
<div class="card"><span class="dim">time used</span><b>{dur}</b></div>
</div>
<div class="grid2">
<div class="card"><h3>by subject</h3><table><thead><tr><th>subject</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{subj}</tbody></table></div>
<div class="card"><h3>by difficulty</h3><table><thead><tr><th>difficulty</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{diff}</tbody></table></div>
</div>
<h2>chapters that cost you marks</h2>
<div class="card"><table><thead><tr><th>subtopic</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{topic}</tbody></table></div>
<h2>every question</h2>
{detail}
<p style="margin-top:18px;display:flex;gap:8px;flex-wrap:wrap">
<a class="btn" href="{b}/history">all papers</a>
<a class="btn" href="{b}/dashboard">dashboard</a>
<a class="btn primary" href="{b}/build">new paper</a>
</p>"#,
score = a.score,
max = a.max_score,
pct = fmt_pct(a.score, a.max_score),
acc = accuracy(a.right, a.wrong),
dur = fmt_dur(a.used_secs),
subj = bucket_rows(&a.by_subject),
diff = bucket_rows(&a.by_difficulty),
topic = bucket_rows(&topic_buckets),
detail = detail,
b = ctx.base
);
shell(ctx, Some(user), "Result", body)
}
pub fn history(ctx: &Ctx, user: &str, tests: &[TestRow]) -> String {
let rows: String = if tests.is_empty() {
String::new()
} else {
tests
.iter()
.map(|t| {
format!(
r#"<tr><td>{d}</td><td>{title}</td><td>{score} / {max}</td><td>{p}%</td><td>{acc}%</td><td>{dur}</td><td><a href="{b}/result/{id}">open</a></td></tr>"#,
d = t.started_at.format("%d %b %Y"),
title = esc(&t.title),
score = t.score,
max = t.max_score,
p = fmt_pct(t.score, t.max_score),
acc = accuracy(t.right, t.wrong),
dur = fmt_dur(t.used_secs),
b = ctx.base,
id = esc(&t.attempt_id)
)
})
.collect()
};
let body = if tests.is_empty() {
format!(
r#"<h1>History</h1><div class="card" style="margin-top:16px;max-width:60ch"><h3>No papers yet</h3><p class="meta">Nothing is here because no paper has been submitted. Build one and it appears here with its score and accuracy.</p><p style="margin-top:12px"><a class="btn primary" href="{b}/build">build your first paper</a></p></div>"#,
b = ctx.base
)
} else {
format!(
r#"<h1>History</h1><div class="card" style="margin-top:16px;overflow-x:auto"><table><thead><tr><th>date</th><th>paper</th><th>score</th><th>%</th><th>accuracy</th><th>time</th><th></th></tr></thead><tbody>{rows}</tbody></table></div>"#,
rows = rows
)
};
shell(ctx, Some(user), "History", body)
}
pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, String, String)], notes: &[String], totals: (i32, i32, i32, i32)) -> String {
let (taken, marks, gained, lost) = totals;
let trend: String = if tests.is_empty() {
String::new()
} else {
let best = tests.iter().map(|t| t.score * 100 / t.max_score.max(1)).max().unwrap_or(0);
let mut s = String::from(r#"<h2>percentage across papers</h2><div class="card"><table><thead><tr><th>date</th><th>paper</th><th>%</th><th>accuracy</th></tr></thead><tbody>"#);
for t in tests {
s.push_str(&format!(
r#"<tr><td>{}</td><td>{}</td><td>{}%</td><td>{}%</td></tr>"#,
t.started_at.format("%d %b"),
esc(&t.title),
fmt_pct(t.score, t.max_score),
accuracy(t.right, t.wrong)
));
}
s.push_str("</tbody></table></div>");
s = s.replace("{best}", &best.to_string());
s
};
let weak_rows: String = weak
.iter()
.map(|(b, chapter, subtopic)| {
format!(
r#"<tr><td>{sub} <span class="dim">· {ch}</span></td><td>{t}</td><td>{w}</td><td>{s}</td><td>{seg}</td><td class="{st}">{st}</td></tr>"#,
sub = esc(subtopic),
ch = esc(chapter),
t = b.total(),
w = b.wrong,
s = b.skipped,
seg = seg(b),
st = b.status()
)
})
.collect();
let notes_html: String = notes.iter().map(|n| format!("<li>{}</li>", esc(n))).collect();
let body = if tests.is_empty() {
format!(
r#"<h1>Dashboard</h1><div class="card" style="margin-top:16px;max-width:60ch"><h3>No test data yet</h3><p class="meta">The trend, the weak-subtopic table and the coach notes all come from papers you submit, so there is nothing to show until the first one is done.</p><p style="margin-top:12px"><a class="btn primary" href="{b}/build">build a paper</a></p></div>"#,
b = ctx.base
)
} else {
format!(
r#"<h1>Dashboard</h1>
<div class="kv">
<div class="card"><span class="dim">papers submitted</span><b>{taken}</b></div>
<div class="card"><span class="dim">marks scored</span><b>{marks}</b></div>
<div class="card"><span class="dim">correct</span><b class="ok-t">{gained}</b></div>
<div class="card"><span class="dim">wrong</span><b class="bad-t">{lost}</b></div>
</div>
{trend}
<h2>weakest subtopics across all papers</h2>
<div class="card"><table><thead><tr><th>subtopic</th><th>seen</th><th>wrong</th><th>blank</th><th></th><th></th></tr></thead><tbody>{weak}</tbody></table></div>
<h2>coach notes</h2>
<div class="card"><ul style="padding-left:18px;display:grid;gap:6px">{notes}</ul><p class="dim" style="margin-top:10px">Written from your own submitted papers. No data leaves this machine.</p></div>
<p style="margin-top:18px"><a class="btn" href="{b}/history">all papers</a></p>"#,
taken = taken,
marks = marks,
gained = gained,
lost = lost,
trend = trend,
weak = weak_rows,
notes = notes_html,
b = ctx.base
)
};
shell(ctx, Some(user), "Dashboard", body)
}
pub fn shared(ctx: &Ctx, title: &str, duration: i64, questions: &[Question]) -> String {
let qs: String = questions
.iter()
.enumerate()
.map(|(i, q)| {
let opts = if q.qtype == "NUM" {
String::new()
} else {
["A", "B", "C", "D"]
.iter()
.enumerate()
.map(|(k, l)| format!("<p><b>{l}</b> {}</p>", esc(&q.opts[k])))
.collect()
};
format!(
r#"<details class="card" style="margin-bottom:8px"><summary style="cursor:pointer">q{n} · {subj} · {sub2}</summary><p style="margin:12px 0 8px;color:#fff">{stem}</p>{opts}</details>"#,
n = i + 1,
subj = esc(&q.subject),
sub2 = esc(&q.subtopic),
stem = esc(&q.stem),
opts = opts
)
})
.collect();
let body = format!(
r#"<h1>{title}</h1><p class="meta">{count} questions · {dur} minutes · no answers or scores on this page.</p><div style="margin-top:16px">{qs}</div>"#,
title = esc(title),
count = questions.len(),
dur = duration,
qs = qs
);
shell(ctx, None, title, body)
}

133
static/exam.js Normal file
View file

@ -0,0 +1,133 @@
// Exam screen: pane navigation, palette sync, per-question timing and autosave.
(() => {
const panes = [...document.querySelectorAll('.qpane')];
const palBtns = [...document.querySelectorAll('#pal .pal')];
const timerEl = document.getElementById('timer');
let cur = 0;
const entered = new Map();
for (const p of panes) {
const box = p.querySelector('input');
if (box) entered.set(p.dataset.i, Number(box.dataset.t || 0));
}
function show(i) {
cur = Math.max(0, Math.min(panes.length - 1, i));
panes.forEach((p, k) => { p.hidden = k !== cur; });
palBtns.forEach((b, k) => b.classList.toggle('cur', k === cur));
const focusable = panes[cur].querySelector('input:not([type=hidden])');
if (focusable && matchMedia('(pointer: fine)').matches) focusable.focus({ preventScroll: true });
history.replaceState(null, '', '#q' + (cur + 1));
}
function questionId(pane) {
const radios = [...pane.querySelectorAll('input[type=radio]')];
if (radios.length) return Number(radios[0].name.replace('q-', ''));
return Number(pane.querySelector('input[type=text]').name.replace('num-', ''));
}
function collect(pane) {
const i = pane.dataset.i;
const radios = [...pane.querySelectorAll('input[type=radio]')];
const picked = radios.find(r => r.checked);
const num = pane.querySelector('input[type=text]');
return {
question_id: questionId(pane),
selected: picked ? picked.value : (num ? num.value.trim() : ''),
marked_review: pane.dataset.review === '1',
time_secs: Number(entered.get(i) || 0),
};
}
function syncPalette(pane) {
const btn = palBtns[Number(pane.dataset.i)];
if (!btn) return;
const answered = collect(pane).selected !== '';
const review = pane.dataset.review === '1';
btn.classList.toggle('ans', answered && !review);
btn.classList.toggle('rev', answered && review);
btn.classList.toggle('skip', !answered);
btn.classList.remove('cur');
}
let saving = false;
async function save(all) {
if (saving) return;
saving = true;
const panesToSave = all ? panes : [panes[cur]];
const attemptId = document.getElementById('exam-form').dataset.attempt;
try {
await fetch(`/attempt/${attemptId}/answer`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ answers: panesToSave.map(collect) }),
});
} catch (_) {
// Server unreachable: the selection stays on screen and travels with the form submit.
}
saving = false;
}
for (const pane of panes) {
pane.addEventListener('change', () => {
pane.querySelectorAll('.opt').forEach(o => {
const input = o.querySelector('input');
o.classList.toggle('sel', !!input && input.checked);
});
syncPalette(pane);
save(false);
});
pane.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-act]');
if (!btn) return;
const act = btn.dataset.act;
if (act === 'save') { save(false).then(() => show(cur + 1)); }
if (act === 'review') {
pane.dataset.review = pane.dataset.review === '1' ? '0' : '1';
btn.classList.toggle('danger', pane.dataset.review === '1');
syncPalette(pane);
save(false);
}
if (act === 'clear') {
pane.querySelectorAll('input[type=radio]').forEach(r => { r.checked = false; });
const num = pane.querySelector('input[type=text]');
if (num) num.value = '';
pane.querySelectorAll('.opt').forEach(o => o.classList.remove('sel'));
syncPalette(pane);
save(false);
}
});
}
palBtns.forEach((b, i) => b.addEventListener('click', () => show(i)));
document.addEventListener('keydown', e => {
if (/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
if (e.key === 'ArrowRight') show(cur + 1);
if (e.key === 'ArrowLeft') show(cur - 1);
});
let left = Number(timerEl.dataset.secs || 0);
if (left > 0) {
setInterval(() => {
left -= 1;
if (left <= 0) {
clearInterval(this);
document.getElementById('exam-form').requestSubmit();
return;
}
const h = String(Math.floor(left / 3600)).padStart(2, '0');
const m = String(Math.floor(left / 60) % 60).padStart(2, '0');
const s = String(left % 60).padStart(2, '0');
timerEl.textContent = `${h}:${m}:${s}`;
}, 1000);
}
document.getElementById('exam-form').addEventListener('submit', e => {
if (!confirm('Submit this paper? You cannot change answers afterwards.')) e.preventDefault();
});
const hash = Number((location.hash.match(/q(\d+)/) || [])[1]);
show(hash ? hash - 1 : 0);
if (cur === 0 && !hash) panes.forEach(syncPalette);
})();

64
static/style.css Normal file
View file

@ -0,0 +1,64 @@
*{box-sizing:border-box;margin:0;padding:0;border-radius:0!important}
:root{--bg:#0b0b0b;--panel:#111111;--card:#141414;--input:#0e0e0e;--border:#2e2e2e;--border2:#3d3d3d;--edge:#626262;--txt:#e5e5e5;--body:#cccccc;--muted:#9a9a9a;--dim:#7d7d7d;--ok:#10b981;--cyan:#38bdf8;--amber:#f59e0b;--bad:#ff5555;--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
html{background:var(--bg)}
body{background:var(--bg);color:var(--body);font-family:var(--mono);font-size:14px;line-height:1.55;max-width:1180px;margin:0 auto;padding:0 16px 64px}
a{color:var(--cyan);text-decoration:none}
a:hover{text-decoration:underline}
h1{font-size:2.1rem;font-weight:600;letter-spacing:-.04em;color:#fff;line-height:1.15;margin:18px 0 10px}
h2{font-size:.76rem;font-weight:500;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin:28px 0 12px;border-bottom:1px solid var(--border);padding-bottom:8px}
h3{color:#fff;font-size:1rem;margin:0 0 6px}
.topbar{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);padding:12px 0;position:sticky;top:0;background:rgba(11,11,11,.92);z-index:5}
.brand{color:#fff;font-weight:600;font-size:1rem;letter-spacing:-.02em}
.brand span{color:var(--dim);font-weight:400;font-size:.78rem;margin-left:10px}
.nav{display:flex;gap:14px;align-items:center;font-size:.8rem}
.btn{display:inline-flex;align-items:center;justify-content:center;padding:8px 18px;background:#161616;color:#ddd;border:1px solid var(--edge);font-size:.8rem;font-family:inherit;cursor:pointer;letter-spacing:.02em;transition:all .15s ease;text-decoration:none}
.btn:hover:not(:disabled){background:#242424;border-color:#555;color:#fff;text-decoration:none}
.btn:active:not(:disabled){background:#fff;color:#000;border-color:#fff}
.btn:disabled{opacity:.3;cursor:not-allowed}
.btn.primary{background:var(--cyan);border-color:var(--cyan);color:#001018;font-weight:600}
.btn.primary:hover{background:#7cd4fc;color:#000}
.btn.ok{border-color:var(--ok);color:var(--ok)}
.btn.danger{border-color:var(--bad);color:var(--bad)}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.card{background:var(--card);border:1px solid var(--border);padding:16px}
.card:hover{border-color:var(--border2)}
.meta{color:var(--muted);font-size:.78rem}
.dim{color:var(--dim);font-size:.78rem}
table{width:100%;border-collapse:collapse;font-size:.82rem}
th{color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-size:.68rem;text-align:left;padding:8px;border-bottom:1px solid var(--border)}
td{padding:8px;border-bottom:1px solid #1c1c1c;font-variant-numeric:tabular-nums}
input[type=text],input[type=email],input[type=number],select,textarea{background:var(--input);color:#e5e5e5;border:1px solid var(--edge);padding:9px 12px;font-family:inherit;font-size:.82rem;outline:none;width:100%}
input:focus,textarea:focus,select:focus{border-color:var(--cyan)}
input::placeholder{color:var(--dim)}
label{display:block;font-size:.75rem;color:var(--muted);margin:10px 0 4px;text-transform:uppercase;letter-spacing:.08em}
.chk{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--edge);padding:10px 12px;margin:6px 0;background:var(--panel);cursor:pointer;font-size:.85rem}
.chk:hover{border-color:var(--border2)}
.chk input{width:auto;margin-top:3px;accent-color:var(--cyan)}
.palette{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}
.pal{aspect-ratio:1;display:grid;place-items:center;border:1px solid var(--edge);background:var(--panel);color:var(--muted);font-size:.75rem;cursor:pointer;min-height:44px}
.pal.cur{border-color:var(--cyan);color:#fff}
.pal.ans{background:#12301f;border-color:var(--ok);color:var(--ok)}
.pal.rev{background:#2a1f05;border-color:var(--amber);color:var(--amber)}
.pal.skip{background:var(--panel)}
.opt{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--edge);padding:10px 12px;margin:8px 0;background:var(--panel);cursor:pointer}
.opt:hover{border-color:var(--border2)}
.opt.sel{border-color:var(--cyan);background:#0d1b24}
.status-dot{width:6px;height:6px;background:#444;display:inline-block;flex:none}
.ok-t{color:var(--ok)}.bad-t{color:var(--bad)}.warn-t{color:var(--amber)}.cyan-t{color:var(--cyan)}
.bar{height:2px;background:#1c1c1c;overflow:hidden;margin-top:8px}
.bar i{display:block;height:100%;background:var(--ok)}
.seg{display:flex;height:10px;background:#1c1c1c;overflow:hidden}
.seg i{display:block;height:100%}
:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
.cbt{display:grid;grid-template-columns:1fr 220px;gap:14px}
.timer{font-variant-numeric:tabular-nums;color:var(--amber);font-weight:600}
.kv{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:14px 0}
.kv .card b{display:block;font-size:1.5rem;color:#fff;font-variant-numeric:tabular-nums}
footer{margin-top:48px;border-top:1px solid var(--border);padding-top:16px;color:var(--dim);font-size:.75rem;display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
/* Tablet: the CBT palette drops under the question instead of squeezing it. */
@media(max-width:1024px){.cbt{grid-template-columns:1fr}.palette{grid-template-columns:repeat(8,1fr)}}
/* Phone: one column throughout, a 2x2 metric grid and full-width palette taps. */
@media(max-width:700px){.grid2,.grid3{grid-template-columns:1fr}.kv{grid-template-columns:1fr 1fr}h1{font-size:1.6rem}.nav a.hidem{display:none}.palette{grid-template-columns:repeat(5,1fr)}.cbt{gap:10px}.topbar{padding:10px 0}.brand span{display:none}}
@media(max-width:420px){.kv{grid-template-columns:1fr}.palette{grid-template-columns:repeat(4,1fr)}.brand{font-size:.9rem}.btn{padding:10px 14px;min-height:44px}}
@media(hover:none){.pal{min-height:48px}}

23
tests/form_decode.rs Normal file
View file

@ -0,0 +1,23 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Repeated {
chapters: Vec<String>,
}
#[test]
fn repeated_keys_do_not_decode_into_vec() {
// Documents the behaviour that forced comma-separated ids on the build form.
let out = serde_urlencoded::from_str::<Repeated>("chapters=1&chapters=4");
assert!(out.is_err(), "serde_urlencoded keeps the last repeated key only");
}
#[test]
fn comma_separated_ids_decode() {
#[derive(Deserialize, Debug)]
struct Cs {
chapters: String,
}
let v = serde_urlencoded::from_str::<Cs>("chapters=1,4,7").expect("decodes");
assert_eq!(v.chapters, "1,4,7");
}

95
tests/grading.rs Normal file
View file

@ -0,0 +1,95 @@
// Grading rules: MCQ comparison, numerical tolerance, and the +4 / −1 scheme.
use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq)]
struct Q {
qtype: &'static str,
answer: String,
}
fn numerical_ok(selected: &str, answer: &str) -> bool {
let s: f64 = match selected.trim().parse() {
Ok(v) => v,
Err(_) => return false,
};
let a: f64 = match answer.trim().parse() {
Ok(v) => v,
Err(_) => return false,
};
let tol = a.abs() * 1e-3 + 1e-6;
(s - a).abs() <= tol
}
fn is_correct(q: &Q, selected: &str) -> bool {
if selected.trim().is_empty() {
return false;
}
if q.qtype == "NUM" {
numerical_ok(selected, &q.answer)
} else {
selected.trim().eq_ignore_ascii_case(q.answer.trim())
}
}
fn marks(q: &Q, selected: &str) -> i32 {
if selected.trim().is_empty() {
0
} else if is_correct(q, selected) {
4
} else {
-1
}
}
#[test]
fn mcq_is_case_insensitive_and_trimmed() {
let q = Q { qtype: "MCQ", answer: "A".into() };
assert!(is_correct(&q, "a"));
assert!(is_correct(&q, " A "));
assert!(!is_correct(&q, "B"));
}
#[test]
fn blank_answer_is_wrong_not_skipped() {
let q = Q { qtype: "MCQ", answer: "A".into() };
assert_eq!(marks(&q, ""), 0, "a blank question scores zero, not minus one");
assert_eq!(marks(&q, " "), 0);
}
#[test]
fn wrong_answer_costs_one_mark() {
let q = Q { qtype: "MCQ", answer: "A".into() };
assert_eq!(marks(&q, "C"), -1);
}
#[test]
fn correct_answer_scores_four() {
let q = Q { qtype: "MCQ", answer: "A".into() };
assert_eq!(marks(&q, "A"), 4);
}
#[test]
fn numerical_answers_allow_small_float_error() {
let q = Q { qtype: "NUM", answer: "20".into() };
assert!(is_correct(&q, "20.0"));
assert!(is_correct(&q, "20.01"), "within 0.1 percent tolerance");
assert!(!is_correct(&q, "20.5"), "outside tolerance");
assert!(!is_correct(&q, "abc"), "non numeric input is wrong");
}
#[test]
fn zero_answer_is_not_mistaken_for_blank() {
let q = Q { qtype: "NUM", answer: "0".into() };
assert!(is_correct(&q, "0"));
assert_eq!(marks(&q, "0"), 4);
}
#[test]
fn mixed_paper_score_matches_hand_calculation() {
let mut answers = HashMap::new();
answers.insert(1i64, ("A".to_string(), 4)); // correct
answers.insert(2i64, ("B".to_string(), -1)); // wrong
answers.insert(3i64, (String::new(), 0)); // blank
let total: i32 = answers.values().map(|(_, m)| m).sum();
assert_eq!(total, 3);
}