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:
parent
ad9eba1ca3
commit
4427837e7a
4 changed files with 166 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
75
src/main.rs
75
src/main.rs
|
|
@ -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, ¬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<CtxArc>, Path(token): Path<String>) -> axum::response::Response {
|
async fn shared_paper(State(ctx): State<CtxArc>, Path(token): Path<String>) -> axum::response::Response {
|
||||||
|
|
|
||||||
76
src/views.rs
76
src/views.rs
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue