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,
|
||||
}
|
||||
|
||||
/// 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)>> {
|
||||
let rows = sqlx::query(
|
||||
"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}/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<CtxArc>, 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<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 })
|
||||
.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();
|
||||
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 {
|
||||
let (uid, name) = match ctx.require_user(&h).await {
|
||||
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 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<CtxArc>, 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<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 correct: bool,
|
||||
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 {
|
||||
|
|
@ -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
|
|||
<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>"#,
|
||||
<p style="color:#38bdf8">{sol}</p>{mform}</details>"#,
|
||||
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!("<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() {
|
||||
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>"#,
|
||||
|
|
@ -818,9 +886,11 @@ pub fn dashboard(ctx: &Ctx, user: &str, tests: &[TestRow], weak: &[(Bucket, Stri
|
|||
{trend}
|
||||
<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>
|
||||
{mistakes_html}
|
||||
<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>"#,
|
||||
mistakes_html = mistakes_html,
|
||||
taken = taken,
|
||||
marks = marks,
|
||||
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.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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue