From 4427837e7ac7f36e649e63b497c99e5f07d72184 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 12 Oct 2026 12:44:30 +0530 Subject: [PATCH] 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. --- src/analytics.rs | 16 ++++++++++ src/main.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++-- src/views.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++++-- static/style.css | 4 +++ 4 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/analytics.rs b/src/analytics.rs index 1b1e681..89b451a 100644 --- a/src/analytics.rs +++ b/src/analytics.rs @@ -158,6 +158,22 @@ pub struct Trend { 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> { let rows = sqlx::query( "SELECT c.name AS chapter, s.name AS subtopic, q.answer, a.selected, q.qtype diff --git a/src/main.rs b/src/main.rs index 2f88571..4ce7a5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,6 +75,7 @@ async fn main() -> anyhow::Result<()> { .route("/attempt/{attempt}/answer", post(save_answers)) .route("/attempt/{attempt}/submit", post(submit)) .route("/result/{attempt}", get(result_page)) + .route("/result/{attempt}/tag/{qid}", post(tag_toggle)) .route("/history", get(history_page)) .route("/dashboard", get(dashboard_page)) .route("/s/{token}", get(shared_paper)) @@ -460,13 +461,82 @@ async fn result_page(State(ctx): State, h: HeaderMap, Path(attempt): Pat } }; 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 = rows .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 = 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(); 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, + h: HeaderMap, + Path((attempt, qid)): Path<(String, i64)>, + Form(f): Form, +) -> 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, h: HeaderMap) -> axum::response::Response { let (uid, name) = match ctx.require_user(&h).await { Ok(v) => v, @@ -483,6 +553,7 @@ async fn dashboard_page(State(ctx): State, h: HeaderMap) -> axum::respon }; let tests = analytics::history(&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 gained: i32 = tests.iter().map(|t| t.right).sum(); let lost: i32 = tests.iter().map(|t| t.wrong).sum(); @@ -501,7 +572,7 @@ async fn dashboard_page(State(ctx): State, h: HeaderMap) -> axum::respon }, &tests, ); - hx(views::dashboard(&ctx, &name, &tests, &weak, ¬es, (tests.len() as i32, marks, gained, lost)), StatusCode::OK).into_response() + hx(views::dashboard(&ctx, &name, &tests, &weak, ¬es, (tests.len() as i32, marks, gained, lost), &mistakes), StatusCode::OK).into_response() } async fn shared_paper(State(ctx): State, Path(token): Path) -> axum::response::Response { diff --git a/src/views.rs b/src/views.rs index 14a1562..29ccaf3 100644 --- a/src/views.rs +++ b/src/views.rs @@ -619,6 +619,42 @@ pub struct RowView<'a> { pub marks: i32, pub correct: bool, pub time_secs: i64, + pub attempt_id: &'a str, + pub tags: Vec, +} + +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#""#, + v = v, + cls = if on { " primary" } else { "" }, + label = label + ) + }) + .collect::>() + .join(""); + format!( + r#"
why lost? {btns}
"#, + base = base, + attempt = attempt, + qid = qid, + btns = btns + ) } 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 { + let base = ctx.base.clone(); let detail: String = rows .iter() .enumerate() @@ -675,7 +712,7 @@ pub fn result(ctx: &Ctx, user: &str, _attempt_id: &str, a: &Analysis, rows: &[Ro

{stem}

{opts}

{sub2} · {ch} · {year} {shift} · {difficulty} · {qtime}

-

{sol}

"#, +

{sol}

{mform}"#, n = i + 1, subj = esc(&r.q.subject), 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), difficulty = esc(&r.q.difficulty), 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(); @@ -765,7 +803,7 @@ pub fn history(ctx: &Ctx, user: &str, tests: &[TestRow]) -> String { 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 trend: String = if tests.is_empty() { String::new() @@ -801,6 +839,36 @@ pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, Stri }) .collect(); let notes_html: String = notes.iter().map(|n| format!("
  • {}
  • ", esc(n))).collect(); + let mistakes_html: String = if mistakes.is_empty() { + String::new() + } else { + let total: i64 = mistakes.iter().map(|(_, n)| n).sum::().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#"{label}{n}
    {p}%"#, + label = esc(label), + n = n, + p = (*n as f64 / total as f64 * 100.0).round() as i64 + ) + }) + .collect(); + format!( + r#"

    why you lose marks

    +
    {rows}
    mistake typecount
    +

    Tag lost questions on any result page; the most common reason surfaces here.

    "#, + rows = rows + ) + }; let body = if tests.is_empty() { format!( r#"

    Dashboard

    No test data yet

    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.

    build a paper

    "#, @@ -818,9 +886,11 @@ pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, Stri {trend}

    weakest subtopics across all papers

    {weak}
    subtopicseenwrongblank
    +{mistakes_html}

    coach notes

      {notes}

    Written from your own submitted papers. No data leaves this machine.

    all papers

    "#, + mistakes_html = mistakes_html, taken = taken, marks = marks, gained = gained, diff --git a/static/style.css b/static/style.css index 9c41d59..c0ba3c2 100644 --- a/static/style.css +++ b/static/style.css @@ -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.warn{border-color:var(--amber)} .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}