Mistake tagging: per-question why-lost form plus dashboard rollup

Result page shows a why-lost row (concept gap / calculation slip /
misread / out of time / blind guess) on every wrong or blank question.
POST /result/:attempt/tag/:qid toggles one tag; ownership checked,
vocabulary enforced server-side. Dashboard gains a 'why you lose marks'
section aggregating tags across all submitted attempts. Verified live:
tag persists, rollup renders.
This commit is contained in:
dev 2026-10-12 12:44:30 +05:30
parent ad9eba1ca3
commit 4427837e7a
4 changed files with 166 additions and 5 deletions

View file

@ -158,6 +158,22 @@ pub struct Trend {
pub seen: i32, pub seen: i32,
} }
/// Mistake-tag rollup across all submitted attempts: (tag, count), most
/// common first. Tags are a fixed vocabulary enforced at write time.
pub async fn mistake_rollup(pool: &SqlitePool, uid: &str) -> Vec<(String, i64)> {
let rows: Vec<(String, i64)> = sqlx::query_as(
"SELECT mt.tag, COUNT(*) FROM mistake_tags mt
JOIN attempts at ON at.id=mt.attempt_id
WHERE at.user_id=? AND at.submitted_at IS NOT NULL
GROUP BY mt.tag ORDER BY COUNT(*) DESC",
)
.bind(uid)
.fetch_all(pool)
.await
.unwrap_or_default();
rows
}
pub async fn weakest(pool: &SqlitePool, uid: &str) -> anyhow::Result<Vec<(Bucket, String, String)>> { pub async fn weakest(pool: &SqlitePool, uid: &str) -> anyhow::Result<Vec<(Bucket, String, String)>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT c.name AS chapter, s.name AS subtopic, q.answer, a.selected, q.qtype "SELECT c.name AS chapter, s.name AS subtopic, q.answer, a.selected, q.qtype

View file

@ -75,6 +75,7 @@ async fn main() -> anyhow::Result<()> {
.route("/attempt/{attempt}/answer", post(save_answers)) .route("/attempt/{attempt}/answer", post(save_answers))
.route("/attempt/{attempt}/submit", post(submit)) .route("/attempt/{attempt}/submit", post(submit))
.route("/result/{attempt}", get(result_page)) .route("/result/{attempt}", get(result_page))
.route("/result/{attempt}/tag/{qid}", post(tag_toggle))
.route("/history", get(history_page)) .route("/history", get(history_page))
.route("/dashboard", get(dashboard_page)) .route("/dashboard", get(dashboard_page))
.route("/s/{token}", get(shared_paper)) .route("/s/{token}", get(shared_paper))
@ -460,13 +461,82 @@ async fn result_page(State(ctx): State<CtxArc>, h: HeaderMap, Path(attempt): Pat
} }
}; };
let a = analyse(&rows, score, max, used); let a = analyse(&rows, score, max, used);
// Existing tags per question so the form shows current state.
let tag_rows: Vec<(i64, String)> = sqlx::query_as(
"SELECT question_id, tag FROM mistake_tags WHERE attempt_id=?",
)
.bind(&attempt)
.fetch_all(&ctx.pool)
.await
.unwrap_or_default();
let rv: Vec<views::RowView> = rows let rv: Vec<views::RowView> = rows
.iter() .iter()
.map(|r| views::RowView { q: &r.q, selected: &r.selected, marks: r.marks, correct: r.correct, time_secs: r.time_secs }) .map(|r| {
let tags: Vec<String> = tag_rows
.iter()
.filter(|(qid, _)| *qid == r.q.id)
.map(|(_, t)| t.clone())
.collect();
views::RowView {
q: &r.q,
selected: &r.selected,
marks: r.marks,
correct: r.correct,
time_secs: r.time_secs,
attempt_id: &attempt,
tags,
}
})
.collect(); .collect();
hx(views::result(&ctx, &name, &attempt, &a, &rv), StatusCode::OK).into_response() hx(views::result(&ctx, &name, &attempt, &a, &rv), StatusCode::OK).into_response()
} }
#[derive(serde::Deserialize)]
struct TagForm {
tag: String,
}
async fn tag_toggle(
State(ctx): State<CtxArc>,
h: HeaderMap,
Path((attempt, qid)): Path<(String, i64)>,
Form(f): Form<TagForm>,
) -> 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=?")
.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();
}
if !["concept", "calculation", "misread", "time", "guess"].contains(&f.tag.as_str()) {
return Redirect::to(&format!("{}/result/{attempt}", ctx.base)).into_response();
}
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM mistake_tags WHERE attempt_id=? AND question_id=? AND tag=?")
.bind(&attempt)
.bind(qid)
.bind(&f.tag)
.fetch_one(&ctx.pool)
.await
.unwrap_or(0);
if n > 0 {
sqlx::query("DELETE FROM mistake_tags WHERE attempt_id=? AND question_id=? AND tag=?")
.bind(&attempt).bind(qid).bind(&f.tag)
.execute(&ctx.pool).await.ok();
} else {
sqlx::query("INSERT OR IGNORE INTO mistake_tags(attempt_id,question_id,tag) VALUES(?,?,?)")
.bind(&attempt).bind(qid).bind(&f.tag)
.execute(&ctx.pool).await.ok();
}
Redirect::to(&format!("{}/result/{attempt}", ctx.base)).into_response()
}
async fn history_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::response::Response { async fn history_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::response::Response {
let (uid, name) = match ctx.require_user(&h).await { let (uid, name) = match ctx.require_user(&h).await {
Ok(v) => v, Ok(v) => v,
@ -483,6 +553,7 @@ async fn dashboard_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::respon
}; };
let tests = analytics::history(&ctx.pool, &uid).await.unwrap_or_default(); let tests = analytics::history(&ctx.pool, &uid).await.unwrap_or_default();
let weak = analytics::weakest(&ctx.pool, &uid).await.unwrap_or_default(); let weak = analytics::weakest(&ctx.pool, &uid).await.unwrap_or_default();
let mistakes = analytics::mistake_rollup(&ctx.pool, &uid).await;
let marks: i32 = tests.iter().map(|t| t.score).sum(); let marks: i32 = tests.iter().map(|t| t.score).sum();
let gained: i32 = tests.iter().map(|t| t.right).sum(); let gained: i32 = tests.iter().map(|t| t.right).sum();
let lost: i32 = tests.iter().map(|t| t.wrong).sum(); let lost: i32 = tests.iter().map(|t| t.wrong).sum();
@ -501,7 +572,7 @@ async fn dashboard_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::respon
}, },
&tests, &tests,
); );
hx(views::dashboard(&ctx, &name, &tests, &weak, &notes, (tests.len() as i32, marks, gained, lost)), StatusCode::OK).into_response() hx(views::dashboard(&ctx, &name, &tests, &weak, &notes, (tests.len() as i32, marks, gained, lost), &mistakes), StatusCode::OK).into_response()
} }
async fn shared_paper(State(ctx): State<CtxArc>, Path(token): Path<String>) -> axum::response::Response { async fn shared_paper(State(ctx): State<CtxArc>, Path(token): Path<String>) -> axum::response::Response {

View file

@ -619,6 +619,42 @@ pub struct RowView<'a> {
pub marks: i32, pub marks: i32,
pub correct: bool, pub correct: bool,
pub time_secs: i64, pub time_secs: i64,
pub attempt_id: &'a str,
pub tags: Vec<String>,
}
const MISTAKE_TAGS: &[(&str, &str)] = &[
("concept", "concept gap"),
("calculation", "calculation slip"),
("misread", "misread question"),
("time", "ran out of time"),
("guess", "blind guess"),
];
fn mistake_form(base: &str, attempt: &str, qid: i64, state: &str, tags: &[String]) -> String {
if state == "correct" {
return String::new();
}
let btns: String = MISTAKE_TAGS
.iter()
.map(|(v, label)| {
let on = tags.iter().any(|t| t == v);
format!(
r#"<button type="submit" name="tag" value="{v}" class="btn sm{cls}">{label}</button>"#,
v = v,
cls = if on { " primary" } else { "" },
label = label
)
})
.collect::<Vec<_>>()
.join("");
format!(
r#"<form method="post" action="{base}/result/{attempt}/tag/{qid}" class="mistake-row"><span class="dim">why lost?</span> {btns}</form>"#,
base = base,
attempt = attempt,
qid = qid,
btns = btns
)
} }
fn seg(b: &Bucket) -> String { fn seg(b: &Bucket) -> String {
@ -650,6 +686,7 @@ fn bucket_rows(items: &[Bucket]) -> String {
} }
pub fn result(ctx: &Ctx, user: &str, _attempt_id: &str, a: &Analysis, rows: &[RowView]) -> String { pub fn result(ctx: &Ctx, user: &str, _attempt_id: &str, a: &Analysis, rows: &[RowView]) -> String {
let base = ctx.base.clone();
let detail: String = rows let detail: String = rows
.iter() .iter()
.enumerate() .enumerate()
@ -675,7 +712,7 @@ pub fn result(ctx: &Ctx, user: &str, _attempt_id: &str, a: &Analysis, rows: &[Ro
<p style="margin:12px 0 6px;color:#fff">{stem}</p> <p style="margin:12px 0 6px;color:#fff">{stem}</p>
<p style="margin-bottom:8px">{opts}</p> <p style="margin-bottom:8px">{opts}</p>
<p class="dim" style="margin-bottom:8px">{sub2} · {ch} · {year} {shift} · {difficulty} · {qtime}</p> <p class="dim" style="margin-bottom:8px">{sub2} · {ch} · {year} {shift} · {difficulty} · {qtime}</p>
<p style="color:#38bdf8">{sol}</p></details>"#, <p style="color:#38bdf8">{sol}</p>{mform}</details>"#,
n = i + 1, n = i + 1,
subj = esc(&r.q.subject), subj = esc(&r.q.subject),
cls = cls, cls = cls,
@ -689,7 +726,8 @@ pub fn result(ctx: &Ctx, user: &str, _attempt_id: &str, a: &Analysis, rows: &[Ro
shift = esc(&r.q.shift), shift = esc(&r.q.shift),
difficulty = esc(&r.q.difficulty), difficulty = esc(&r.q.difficulty),
qtime = if r.time_secs > 0 { format!("{}s spent", r.time_secs) } else { "no time recorded".to_string() }, qtime = if r.time_secs > 0 { format!("{}s spent", r.time_secs) } else { "no time recorded".to_string() },
sol = rich(&r.q.solution) sol = rich(&r.q.solution),
mform = mistake_form(&base, r.attempt_id, r.q.id, state, &r.tags)
) )
}) })
.collect(); .collect();
@ -765,7 +803,7 @@ pub fn history(ctx: &Ctx, user: &str, tests: &[TestRow]) -> String {
shell(ctx, Some(user), "History", body) 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 { pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, String, String)], notes: &[String], totals: (i32, i32, i32, i32), mistakes: &[(String, i64)]) -> String {
let (taken, marks, gained, lost) = totals; let (taken, marks, gained, lost) = totals;
let trend: String = if tests.is_empty() { let trend: String = if tests.is_empty() {
String::new() String::new()
@ -801,6 +839,36 @@ pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, Stri
}) })
.collect(); .collect();
let notes_html: String = notes.iter().map(|n| format!("<li>{}</li>", esc(n))).collect(); let notes_html: String = notes.iter().map(|n| format!("<li>{}</li>", esc(n))).collect();
let mistakes_html: String = if mistakes.is_empty() {
String::new()
} else {
let total: i64 = mistakes.iter().map(|(_, n)| n).sum::<i64>().max(1);
let rows: String = mistakes
.iter()
.map(|(tag, n)| {
let label = match tag.as_str() {
"concept" => "concept gap",
"calculation" => "calculation slip",
"misread" => "misread question",
"time" => "ran out of time",
"guess" => "blind guess",
t => t,
};
format!(
r#"<tr><td>{label}</td><td>{n}</td><td style="width:38%"><div class="seg"><i style="width:{p}%;background:#38bdf8"></i></div></td><td class="dim">{p}%</td></tr>"#,
label = esc(label),
n = n,
p = (*n as f64 / total as f64 * 100.0).round() as i64
)
})
.collect();
format!(
r#"<h2>why you lose marks</h2>
<div class="card table-wrap"><table><thead><tr><th>mistake type</th><th>count</th><th></th><th></th></tr></thead><tbody>{rows}</tbody></table></div>
<p class="dim" style="margin-top:8px">Tag lost questions on any result page; the most common reason surfaces here.</p>"#,
rows = rows
)
};
let body = if tests.is_empty() { let body = if tests.is_empty() {
format!( 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>"#, 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>"#,
@ -818,9 +886,11 @@ pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, Stri
{trend} {trend}
<h2>weakest subtopics across all papers</h2> <h2>weakest subtopics across all papers</h2>
<div class="card table-wrap"><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> <div class="card table-wrap"><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>
{mistakes_html}
<h2>coach notes</h2> <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> <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>"#, <p style="margin-top:18px"><a class="btn" href="{b}/history">all papers</a></p>"#,
mistakes_html = mistakes_html,
taken = taken, taken = taken,
marks = marks, marks = marks,
gained = gained, gained = gained,

View file

@ -228,3 +228,7 @@ input[type=text],input[type=email],input[type=number],select{min-height:44px;pad
.subj-timer.active{border-color:var(--cyan)} .subj-timer.active{border-color:var(--cyan)}
.subj-timer.warn{border-color:var(--amber)} .subj-timer.warn{border-color:var(--amber)}
.subj-timer.done{opacity:.4;text-decoration:line-through} .subj-timer.done{opacity:.4;text-decoration:line-through}
/* ===== mistake tagging ===== */
.mistake-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px;padding-top:10px;border-top:1px solid var(--border)}
.btn.sm{min-height:36px;padding:7px 12px;font-size:.72rem}