() / pick.len() as i64) }
+ };
+ let timed: Vec<(usize, &RowView)> = rows
+ .iter()
+ .enumerate()
+ .filter(|(_, r)| r.time_secs > 0)
+ .collect();
+ let time_table = if timed.is_empty() {
+ String::new()
+ } else {
+ let time_row = |(i, r): &(usize, &RowView)| {
+ let cls = if r.correct { "ok-t" } else if r.selected.trim().is_empty() { "dim" } else { "bad-t" };
+ format!(
+ r#"| q{n} | {sub} | {t}s |
"#,
+ n = i + 1,
+ cls = cls,
+ sub = esc(&r.q.subtopic),
+ t = r.time_secs
+ )
+ };
+ let mut sorted = timed.clone();
+ sorted.sort_by_key(|(_, r)| r.time_secs);
+ let fast: String = sorted.iter().take(3).map(time_row).collect();
+ let mut slowest = sorted.clone();
+ slowest.reverse();
+ let slow: String = slowest.iter().take(3).map(time_row).collect();
+ format!(
+ r##"time analysis
+
+
average per outcome
| outcome | avg time |
+ | right | {rt} |
+ | wrong | {wt} |
+ | blank | {bt} |
+
+
+
"##,
+ rt = avg_for(&|r| r.correct),
+ wt = avg_for(&|r| !r.correct && !r.selected.trim().is_empty()),
+ bt = avg_for(&|r| r.selected.trim().is_empty()),
+ fast = fast,
+ slow = slow
+ )
+ };
// Encouragement line: tone follows the score, like the ZenithPrep modal.
let pct = fmt_pct(a.score, a.max_score);
let (enc_head, enc_body) = if pct >= 75 {
@@ -1111,6 +1160,7 @@ pub fn result(ctx: &Ctx, user: &str, attempt_id: &str, share_token: &str, a: &An
subject-wise
difficulty
chapters that cost you marks
+ time analysis
per-question analysis{mtag}
all papers
dashboard
@@ -1134,6 +1184,7 @@ pub fn result(ctx: &Ctx, user: &str, attempt_id: &str, share_token: &str, a: &An
chapters that cost you marks
| subtopic | right | wrong | blank | | % | |
{topic}
+{time_table}
every question
{detail}
@@ -1170,6 +1221,7 @@ pub fn result(ctx: &Ctx, user: &str, attempt_id: &str, share_token: &str, a: &An
subj = bucket_rows(&a.by_subject),
diff = bucket_rows(&a.by_difficulty),
topic = bucket_rows(&topic_buckets),
+ time_table = time_table,
mtag = if mistakes_pending > 0 { format!(r#" {}"#, mistakes_pending) } else { String::new() },
title_ = esc(&title),
detail = detail,
diff --git a/static/exam.js b/static/exam.js
index d33a5e1..54e0e0a 100644
--- a/static/exam.js
+++ b/static/exam.js
@@ -15,10 +15,24 @@
for (const p of panes) {
const box = p.querySelector('input');
if (box) entered.set(p.dataset.i, Number(box.dataset.t || 0));
- }
- const visited = new Set();
+ } const visited = new Set();
const saveDot = document.getElementById('save-dot');
+ // Per-question stopwatch: accrues only while the pane is on screen, so
+ // time spent elsewhere never counts against a question.
+ let tickPid = null;
+ function startTick(pane) {
+ if (tickPid) clearInterval(tickPid);
+ const key = pane.dataset.i;
+ tickPid = setInterval(() => {
+ entered.set(key, Number(entered.get(key) || 0) + 1);
+ }, 1000);
+ }
+ document.addEventListener('visibilitychange', () => {
+ if (document.hidden && tickPid) clearInterval(tickPid);
+ else if (!document.hidden) startTick(panes[cur]);
+ });
+
function questionId(pane) {
const radios = [...pane.querySelectorAll('input[type=radio]')];
if (radios.length) return Number(radios[0].name.replace('q-', ''));
@@ -58,6 +72,7 @@
// A pane change on a phone must start the question at the top, not halfway down.
panes[cur].scrollIntoView({ block: 'nearest' });
history.replaceState(null, '', '#q' + (cur + 1));
+ startTick(panes[cur]);
paintProgress();
}