whaleprep/src/views.rs
dev 5c4b8ab7f2 Rename zenith-pyq-clone to whaleprep
Package, binary, tracing filter, footer copy, run_server.sh and README
updated. Footer no longer attributes zenithprepare.com.
2026-10-12 08:17:40 +05:30

808 lines
No EOL
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::analytics::{accuracy, fmt_dur, fmt_pct, Analysis, Bucket, TestRow};
use crate::auth::Ctx;
use crate::exam::{BuildForm, Question};
pub fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len() + 16);
for c in s.chars() {
match c {
'&' => o.push_str("&"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\'' => o.push_str("&#39;"),
_ => o.push(c),
}
}
o
}
pub struct SubtopicRow {
pub id: i64,
pub name: String,
}
pub struct ChapterBlock {
pub id: i64,
pub name: String,
/// Parallel vecs: subtopic_ids[i] matches subtopics[i]
pub subtopic_ids: Vec<i64>,
pub subtopics: Vec<String>,
pub count: i64,
}
pub struct SubjectBlock {
pub subject: String,
pub chapters: Vec<ChapterBlock>,
}
fn head(ctx: &Ctx, title: &str) -> String {
format!(
r#"<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{t} · PYQ</title><link rel="stylesheet" href="{b}/static/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head><body>"#,
t = esc(title),
b = ctx.base
)
}
fn nav(ctx: &Ctx, user: Option<&str>) -> String {
let brand = format!(
r#"<header class="topbar"><a class="brand" href="{b}/">PYQ<span>jee main practice</span></a><nav class="nav">"#,
b = ctx.base
);
let links = match user {
Some(_) => format!(
r#"<a class="hidem" href="{b}/build">build</a><a class="hidem" href="{b}/dashboard">dashboard</a><a class="hidem" href="{b}/history">history</a><a class="btn" href="{b}/logout">sign out</a>"#,
b = ctx.base
),
None => format!(
r#"<a class="hidem" href="{b}/terms">terms</a><a class="hidem" href="{b}/privacy">privacy</a><a class="btn primary" href="{b}/build">start a paper</a>"#,
b = ctx.base
),
};
// Below 700px the row of links has nowhere to go, so the primary destinations
// move to a bottom bar rather than disappearing behind a hidden class.
let tabbar = match user {
Some(_) => format!(
r#"<nav class="tabbar" aria-label="primary"><a href="{b}/build">build</a><a href="{b}/history">history</a><a href="{b}/dashboard">dashboard</a><a href="{b}/logout">sign out</a></nav>"#,
b = ctx.base
),
None => format!(
r#"<nav class="tabbar" aria-label="primary"><a href="{b}/">home</a><a href="{b}/build">start a paper</a><a href="{b}/terms">terms</a></nav>"#,
b = ctx.base
),
};
format!("{brand}{links}</nav></header>{tabbar}")
}
fn foot(ctx: &Ctx) -> String {
format!(
r#"<footer><span>WhalePrep · JEE Main PYQ practice</span><span><a href="{b}/terms">terms</a> · <a href="{b}/privacy">privacy</a></span></footer></body></html>"#,
b = ctx.base
)
}
pub fn shell(ctx: &Ctx, user: Option<&str>, title: &str, body: String) -> String {
format!("{}{}{}{}", head(ctx, title), nav(ctx, user), body, foot(ctx))
}
/// The exam screen carries a floating palette button, so the page needs extra
/// bottom room for it on a phone.
pub fn shell_exam(ctx: &Ctx, user: Option<&str>, title: &str, body: String) -> String {
let page = shell(ctx, user, title, body);
page.replacen("<body>", "<body class=\"exam\">", 1)
}
pub fn landing(ctx: &Ctx, q: i64, shifts: i64, subtopics: i64, subjects: i64, students: i64, signed: bool) -> String {
let cta = if signed {
format!(r#"<a class="btn primary" href="{b}/build">build a paper</a>"#, b = ctx.base)
} else {
format!(r#"<a class="btn primary" href="{b}/build">start a paper</a>"#, b = ctx.base)
};
let body = format!(
r#"<h1>JEE Main practice, built like the real exam screen.</h1>
<p class="meta">Pick the chapters you just revised. Take the paper on a timed computer-based screen with the +4 / −1 marking scheme. Get the analysis the moment you submit.</p>
<div class="kv" style="margin-top:22px">
<div class="card"><span class="dim">questions</span><b>{q}</b></div>
<div class="card"><span class="dim">shifts</span><b>{shifts}</b></div>
<div class="card"><span class="dim">subtopics</span><b>{subtopics}</b></div>
<div class="card"><span class="dim">students</span><b>{students}</b></div>
</div>
<div class="grid2" style="margin-top:18px">
<div class="card"><h3>Build</h3><p class="meta">Choose any mix of chapters across {subjects} subjects. Questions are drawn from real shift data and, with unseen-only on, you never repeat one by accident.</p></div>
<div class="card"><h3>Attempt</h3><p class="meta">Subject tabs, a colour-coded question palette, mark for review, numerical answers, a countdown timer, and +4 for a correct answer with −1 for a wrong one.</p></div>
<div class="card"><h3>Analysis</h3><p class="meta">Score, accuracy, time per question, and results broken down by subject, chapter and difficulty. Solutions sit under every question.</p></div>
<div class="card"><h3>Progress</h3><p class="meta">Every submitted paper is kept. The dashboard tracks percentage and accuracy across tests and names the subtopics costing you the most marks.</p></div>
</div>
<p style="margin-top:20px">{cta}</p>"#,
q = q,
shifts = shifts,
subtopics = subtopics,
subjects = subjects,
students = students,
cta = cta
);
shell(ctx, None, "Home", body)
}
/// PKCE parameters for the full-page fallback: `(state, challenge, redirect uri)`.
pub type Fallback = Option<(String, String, String)>;
pub fn student(ctx: &Ctx, next: &str, err: Option<&str>, fallback: Fallback) -> String {
let e = err.map(|m| format!(r#"<p class="bad-t" role="alert">{m}</p>"#)).unwrap_or_default();
let (intro, google_block, action) = match (&fallback, ctx.google.is_some()) {
(Some(_), true) => (
"Sign in with Google and every paper you build, submit and analyse is attached to that account, so a score is still there on the next device.",
format!(
r#"<div id="google-btn" style="display:flex;justify-content:center;margin:18px 0;min-height:44px"></div>
<form method="post" action="{b}/student/google" id="google-form" hidden>
<input type="hidden" name="credential" value="">
<input type="hidden" name="next" value="{next}">
</form>
<p class="dim" id="gsi-status" role="status">loading the Google button&hellip;</p>"#,
b = ctx.base,
next = esc(next)
),
"sign in with an email instead",
),
(None, true) => (
"Google sign-in is available but its button did not get its parameters. Refresh the page, or use the form below.",
String::new(),
"sign in",
),
_ => (
"Google sign-in is switched off on this server because no client id was configured. The form below keys your papers to an email so the whole flow still works.",
String::new(),
"sign in",
),
};
let body = format!(
r#"<h1>Sign in</h1>
<p class="meta">{intro}</p>
{google_block}
<h2>{action}</h2>
<form method="post" action="{b}/student" style="max-width:420px;margin-top:8px">
<input type="hidden" name="next" value="{next}">
<label for="name">name</label><input id="name" name="name" type="text" autocomplete="name" required>
<label for="email">email</label><input id="email" name="email" type="email" autocomplete="email" required>
{e}
<div style="margin-top:14px"><button class="btn primary" type="submit">{action}</button></div>
</form>
{gsi_script}"#,
intro = intro,
google_block = google_block,
action = action,
b = ctx.base,
next = esc(next),
e = e,
gsi_script = match &fallback {
Some((state, challenge, redirect_uri)) if ctx.google.is_some() => {
gsi_script(&ctx.google_client_id, state, challenge, redirect_uri)
}
_ => String::new(),
}
);
shell(ctx, None, "Sign in", body)
}
/// Google Identity Services. The popup is the normal path; the anchor is the
/// fallback for in-app browsers and hardened settings where a popup is refused.
fn gsi_script(client_id: &str, state: &str, challenge: &str, redirect_uri: &str) -> String {
format!(
r#"<script src="https://accounts.google.com/gsi/client" async defer></script>
<script>
(() => {{
const CLIENT_ID = {cid};
const STATUS = document.getElementById('gsi-status');
const FALLBACK = {url};
// The popup path: Google hands the credential to this page, which posts it on.
function onCredential(r) {{
const f = document.getElementById('google-form');
f.credential.value = r.credential;
f.submit();
}}
function wireFallback() {{
const a = document.getElementById('gsi-full');
a.href = 'https://accounts.google.com/o/oauth2/v2/auth'
+ '?client_id=' + encodeURIComponent(CLIENT_ID)
+ '&redirect_uri=' + encodeURIComponent(FALLBACK.redirect_uri)
+ '&response_type=code'
+ '&scope=' + encodeURIComponent('openid email profile')
+ '&code_challenge=' + encodeURIComponent(FALLBACK.challenge)
+ '&code_challenge_method=S256'
+ '&state=' + encodeURIComponent(FALLBACK.state)
+ '&prompt=select_account';
}}
function init() {{
try {{
google.accounts.id.initialize({{ client_id: CLIENT_ID, callback: onCredential, ux_mode: 'popup', context: 'signin' }});
const box = document.getElementById('google-btn');
google.accounts.id.renderButton(box, {{
theme: 'outline_black', size: 'large', text: 'signin_with', shape: 'rectangular',
width: Math.min(box.clientWidth || 320, 400),
}});
wireFallback();
STATUS.textContent = 'no popup? ';
STATUS.appendChild(document.getElementById('gsi-full'));
}} catch (err) {{
wireFallback();
STATUS.textContent = 'the Google button did not load. ';
STATUS.appendChild(document.getElementById('gsi-full'));
}}
}}
const link = document.createElement('a');
link.id = 'gsi-full';
link.href = '#';
link.textContent = 'use a full page instead';
link.addEventListener('click', e => {{ e.preventDefault(); wireFallback(); location.href = link.href; }});
if (window.google) init();
else window.addEventListener('load', init);
}})();
</script>"#,
cid = serde_json::to_string(client_id).unwrap_or_else(|_| "\"\"".into()),
url = serde_json::to_string(&serde_json::json!({ "redirect_uri": redirect_uri, "challenge": challenge, "state": state }))
.unwrap_or_else(|_| "{}".into())
)
}
pub fn legal(ctx: &Ctx, title: &str, body_md: &str) -> String {
let body = format!(r#"<h1>{t}</h1><div style="max-width:70ch">{b}</div>"#, t = esc(title), b = body_md);
shell(ctx, None, title, body)
}
pub fn error(ctx: &Ctx, code: u16, detail: &str) -> String {
let (t, d) = match code {
404 => ("Nothing at this address".to_string(), "The link may be wrong, or the paper was never created.".to_string()),
_ => ("Something broke on our side".to_string(), detail.to_string()),
};
let body = format!(
r#"<h1>{t}</h1><p class="meta">{d}</p><p style="margin-top:16px"><a class="btn" href="{b}/">back to home</a></p>"#,
t = esc(&t),
d = esc(&d),
b = ctx.base
);
shell(ctx, None, &t, body)
}
pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm, available: i64, err: Option<&str>) -> String {
let mut picker = String::new();
for sb in &blocks {
picker.push_str(&format!(r#"<div class="subject-section"><h3 class="subject-label"><label class="chk all-subj"><input type="checkbox" class="subj-all" data-subj="{s}"><span>{sl}</span></label></h3>"#,
s = esc(&sb.subject), sl = esc(&sb.subject)));
for ch in &sb.chapters {
// Each chapter has a "select all" plus individual subtopic checkboxes.
let subcheck: String = ch.subtopic_ids.iter().zip(ch.subtopics.iter()).map(|(sid, sname)| {
format!(r#"<label class="chk sub-chk"><input type="checkbox" class="subtopic" value="{sid}" data-chap="{cid}"><span class="dim">{sn}</span></label>"#,
sid = sid, cid = ch.id, sn = esc(sname))
}).collect();
picker.push_str(&format!(
r#"<div class="chapter-group">
<label class="chk chapter-hd"><input type="checkbox" class="chap" value="{cid}" data-subj="{subj}"><span><b>{cname}</b> <span class="dim">· {count} q</span></span></label>
<div class="subtopic-list" id="sl-{cid}">{subcheck}</div>
</div>"#,
cid = ch.id, cname = esc(&ch.name), count = ch.count, subj = esc(&sb.subject), subcheck = subcheck
));
}
picker.push_str("</div>");
}
let e = err.map(|m| format!(r#"<p class="bad-t">{m}</p>"#)).unwrap_or_default();
let body = format!(
r#"<h1>Build a paper</h1>
<p class="meta">{available} questions in the bank. Pick chapters or individual subtopics.</p>
<form method="post" action="{b}/build" id="build-form">
<input type="hidden" name="chapters" id="chapters" value="">
<input type="hidden" name="subtopics" id="subtopics" value="">
<input type="hidden" name="mode" id="mode" value="">
<div class="grid2"><div class="picker-col">{picker}</div>
<div class="settings-col">
<div class="settings-sticky">
<h2>paper settings</h2>
<label for="count">questions</label>
<select id="count" name="count">
<option value="9">9 · quick drill</option>
<option value="12" selected>12</option>
<option value="18">18</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="75">75 · full shift</option>
</select>
<label class="chk" style="margin-top:12px">
<input type="checkbox" id="jee-mode" name="_jee">
<span>JEE mode — 60 min per subject<br><span class="dim">ignores the duration field</span></span>
</label>
<div id="dur-row">
<label for="duration" style="margin-top:12px">duration (minutes)</label>
<input id="duration" name="duration" type="number" value="24" min="5" max="180">
</div>
<label class="chk" style="margin-top:12px">
<input type="checkbox" name="unseen_only" checked>
<span>unseen questions only<br><span class="dim">skips anything you have already answered once</span></span>
</label>
<p class="dim build-note" id="pick-note" style="margin-top:10px">no topics picked yet</p>
{e}
<div style="margin-top:14px"><button class="btn primary block" type="submit">create paper</button></div>
</div>
</div></div>
</form>
<script>
(() => {{
const form = document.getElementById('build-form');
const chapField = document.getElementById('chapters');
const stField = document.getElementById('subtopics');
const modeField = document.getElementById('mode');
const note = document.getElementById('pick-note');
const jeeBox = document.getElementById('jee-mode');
const durRow = document.getElementById('dur-row');
// JEE mode hides the flat duration field.
jeeBox.addEventListener('change', () => {{
durRow.hidden = jeeBox.checked;
modeField.value = jeeBox.checked ? 'jee' : '';
}});
// Selecting a chapter expands the subtopic list and checks all subtopics.
document.querySelectorAll('.chap').forEach(ch => {{
ch.addEventListener('change', () => {{
const grp = ch.closest('.chapter-group');
if (grp) grp.classList.toggle('expanded', ch.checked);
document.querySelectorAll(`.subtopic[data-chap="${{ch.value}}"]`).forEach(st => {{
st.checked = ch.checked;
}});
sync();
}});
}});
// Selecting a "select all subjects" box checks all chapters in that subject.
document.querySelectorAll('.subj-all').forEach(sa => {{
sa.addEventListener('change', () => {{
const subj = sa.dataset.subj;
document.querySelectorAll(`.chap[data-subj="${{subj}}"]`).forEach(ch => {{
ch.checked = sa.checked;
document.querySelectorAll(`.subtopic[data-chap="${{ch.value}}"]`).forEach(st => {{ st.checked = sa.checked; }});
}});
sync();
}});
}});
// A subtopic change syncs parent chapter (checked if ANY subtopic checked).
document.querySelectorAll('.subtopic').forEach(st => {{
st.addEventListener('change', () => {{
const chapId = st.dataset.chap;
const chap = document.querySelector(`.chap[value="${{chapId}}"]`);
if (chap) {{
const any = [...document.querySelectorAll(`.subtopic[data-chap="${{chapId}}"]`)].some(s => s.checked);
chap.checked = any;
chap.indeterminate = any && [...document.querySelectorAll(`.subtopic[data-chap="${{chapId}}"]`)].some(s => !s.checked);
}}
sync();
}});
}});
function sync() {{
const chapIds = [...document.querySelectorAll('.chap:checked')].map(c => c.value);
const stIds = [...document.querySelectorAll('.subtopic:checked')].map(s => s.value);
chapField.value = chapIds.join(',');
stField.value = stIds.join(',');
const n = stIds.length || chapIds.length;
note.textContent = n ? `${{chapIds.length}} chapter${{chapIds.length!==1?'s':''}} · ${{stIds.length}} subtopics` : 'no topics picked yet';
}}
sync();
}})();
</script>"#,
available = available,
b = ctx.base,
picker = picker,
e = e
);
shell(ctx, Some(user), "Build", body)
}
fn palette(questions: &[Question], answers: &[(i64, String, bool)]) -> String {
questions
.iter()
.enumerate()
.map(|(i, q)| {
let a = answers.iter().find(|(id, _, _)| *id == q.id);
let sel = a.map(|(_, s, _)| !s.trim().is_empty()).unwrap_or(false);
let rev = a.map(|(_, _, r)| *r).unwrap_or(false);
let cls = match (sel, rev) {
(true, true) => "pal rev",
(true, false) => "pal ans",
_ => "pal",
};
format!(r#"<button type="button" class="{cls}" data-jump="{i}">{n}</button>"#, cls = cls, n = i + 1)
})
.collect::<Vec<_>>()
.join("")
}
pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], answers: &[(i64, String, bool)], duration_min: i64, title: &str) -> String {
let body_questions: Vec<String> = questions
.iter()
.enumerate()
.map(|(i, q)| {
let cur = answers.iter().find(|(id, _, _)| *id == q.id);
let sel = cur.map(|(_, s, _)| s.clone()).unwrap_or_default();
let rev = cur.map(|(_, _, r)| *r).unwrap_or(false);
let opts: Vec<String> = if q.qtype == "NUM" {
vec![format!(
r#"<input type="text" inputmode="decimal" name="num-{id}" value="{v}" placeholder="numeric answer">"#,
id = q.id,
v = esc(&sel)
)]
} else {
["A", "B", "C", "D"]
.iter()
.enumerate()
.map(|(k, letter)| {
let text = q.opts[k].clone();
let checked = if sel.trim().eq_ignore_ascii_case(letter) { " checked" } else { "" };
format!(
r#"<label class="opt{cls}"><input type="radio" name="q-{id}" value="{letter}"{checked}><span><b>{letter}</b> &nbsp;{t}</span></label>"#,
cls = if sel.trim().eq_ignore_ascii_case(letter) { " sel" } else { "" },
id = q.id,
letter = letter,
checked = checked,
t = esc(&text)
)
})
.collect()
};
format!(
r#"<section class="card qpane" id="qpane-{i}" data-i="{i}"{hidden}>
<div class="pane-head">
<span class="dim">question {n} of {total} · {subj}</span>
<span class="dim">{qt} · +4 / −1{revflag}</span>
</div>
<p class="stem">{stem}</p>
<div class="dim meta-line">{sub2} · {ch} · {year} {shift} · {difficulty}</div>
{opts}
<div class="opt-actions">
<button type="button" class="btn" data-act="prev"{prevdis}>previous</button>
<button type="button" class="btn ok" data-act="save">save &amp; next</button>
<button type="button" class="btn" data-act="review"{revcls}>mark for review</button>
<button type="button" class="btn" data-act="clear">clear</button>
</div>
</section>"#,
hidden = if i == 0 { "" } else { " hidden" },
prevdis = if i == 0 { " disabled" } else { "" },
revcls = if rev { " danger" } else { "" },
total = questions.len(),
n = i + 1,
subj = esc(&q.subject),
ch = esc(&q.chapter),
qt = esc(&q.qtype),
revflag = if rev { " · <span class=\"warn-t\">marked</span>" } else { "" },
stem = esc(&q.stem),
sub2 = esc(&q.subtopic),
year = q.year,
shift = esc(&q.shift),
difficulty = esc(&q.difficulty),
opts = opts.join("")
)
})
.collect();
// Group questions by subject to build per-subject timer strips.
let subjects_ordered: Vec<&str> = {
let mut seen = std::collections::LinkedList::new();
let mut added = std::collections::HashSet::new();
for q in questions {
if added.insert(q.subject.as_str()) { seen.push_back(q.subject.as_str()); }
}
seen.into_iter().collect()
};
// Detect JEE mode: duration == 60 * nsubjects (and nsubjects > 1).
let is_jee = subjects_ordered.len() > 1 && duration_min == 60 * subjects_ordered.len() as i64;
let subj_timer_bar = if is_jee {
let strips: String = subjects_ordered.iter().enumerate().map(|(i, subj)| {
let cls = if i == 0 { " active" } else { "" };
let secs = 60 * 60i64; // 60 min per subject
format!(r#"<div class="subj-timer{cls}" data-subj="{s}" data-secs="{secs}" id="st-{i}"><span class="dim">{s}</span><b>{m}:00</b></div>"#,
cls = cls, s = esc(subj), secs = secs, i = i, m = 60)
}).collect();
format!(r#"<div class="subj-timer-bar" id="subj-timers">{strips}</div>"#)
} else { String::new() };
let body = format!(
r#"<div class="exam-bar">
<div class="exam-bar-inner">
<div class="exam-title"><b>{title}</b><span class="dim" id="exam-progress">{n} questions</span></div>
<div class="exam-clock"><span class="dim">time left</span><div class="timer" id="timer" data-secs="{secs}" role="timer">{dur}</div></div>
</div>
<div class="exam-progress"><i id="exam-fill"></i></div>
{stbar}
</div>
<form method="post" action="{b}/attempt/{a}/submit" id="exam-form" data-attempt="{a}" data-base="{b}">
<div class="cbt">
<div>{qs}</div>
<aside class="cbt-side" id="pal-wrap">
<h2>palette</h2>
<div class="palette" id="pal" aria-label="question palette">{pal}</div>
<ul class="legend">
<li><i class="sw ans"></i>answered</li>
<li><i class="sw rev"></i>review</li>
<li><i class="sw skip"></i>blank</li>
</ul>
<p class="cbt-count dim" id="cbt-count">0 answered</p>
<button class="btn primary block" type="submit">submit paper</button>
<button type="button" class="btn block" data-close-palette>close palette</button>
</aside>
</div>
<button type="button" class="btn pal-fab" id="pal-toggle" aria-expanded="false" aria-controls="pal-wrap">palette <span id="pal-fab-count"></span></button>
</form>
<script src="{b}/static/exam.js"></script>"#,
title = esc(title),
n = questions.len(),
dur = fmt_dur(duration_min as i64 * 60),
secs = duration_min * 60,
stbar = subj_timer_bar,
b = ctx.base,
a = esc(attempt_id),
qs = body_questions.join(""),
pal = palette(questions, answers)
);
shell_exam(ctx, Some(user), "Test", body)
}
pub struct RowView<'a> {
pub q: &'a Question,
pub selected: &'a str,
pub marks: i32,
pub correct: bool,
pub time_secs: i64,
}
fn seg(b: &Bucket) -> String {
let t = b.total().max(1) as f64;
format!(
r#"<div class="seg"><i style="width:{}%;background:#10b981"></i><i style="width:{}%;background:#ff5555"></i><i style="width:{}%;background:#333"></i></div>"#,
b.right as f64 / t * 100.0,
b.wrong as f64 / t * 100.0,
b.skipped as f64 / t * 100.0
)
}
fn bucket_rows(items: &[Bucket]) -> String {
items
.iter()
.map(|b| {
format!(
r#"<tr><td>{l}</td><td>{r}</td><td>{w}</td><td>{s}</td><td style="width:38%">{seg}</td><td>{p}%</td><td class="{st}">{st}</td></tr>"#,
l = esc(&b.label),
r = b.right,
w = b.wrong,
s = b.skipped,
seg = seg(b),
p = b.pct(),
st = b.status()
)
})
.collect()
}
pub fn result(ctx: &Ctx, user: &str, _attempt_id: &str, a: &Analysis, rows: &[RowView]) -> String {
let detail: String = rows
.iter()
.enumerate()
.map(|(i, r)| {
let state = if r.correct { "correct" } else if r.selected.trim().is_empty() { "blank" } else { "wrong" };
let cls = match state { "correct" => "ok-t", "wrong" => "bad-t", _ => "dim" };
let opts: String = if r.q.qtype == "NUM" {
format!(r#"<p class="dim">your answer: {sel} · correct: {ans}</p>"#, sel = esc(if r.selected.is_empty() { "not attempted" } else { r.selected }), ans = esc(&r.q.answer))
} else {
["A", "B", "C", "D"]
.iter()
.enumerate()
.map(|(k, l)| {
let is_ans = r.q.answer.trim().eq_ignore_ascii_case(l);
let is_sel = r.selected.trim().eq_ignore_ascii_case(l);
let style = if is_ans { "ok-t" } else if is_sel { "bad-t" } else { "dim" };
format!(r#"<span style="margin-right:10px" class="{style}">{l}: {t}{mark}</span>"#, style = style, l = l, t = esc(&r.q.opts[k]), mark = if is_ans { " (correct)" } else if is_sel { " (you)" } else { "" })
})
.collect()
};
format!(
r#"<details class="card" style="margin-bottom:8px"><summary style="cursor:pointer">q{n} · {subj} · <span class="{cls}">{state}</span> · {marks:+} marks</summary>
<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>"#,
n = i + 1,
subj = esc(&r.q.subject),
cls = cls,
state = state,
marks = r.marks,
stem = esc(&r.q.stem),
opts = opts,
sub2 = esc(&r.q.subtopic),
ch = esc(&r.q.chapter),
year = r.q.year,
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 = esc(&r.q.solution)
)
})
.collect();
let topic_buckets: Vec<Bucket> = a.by_topic.iter().map(|(b, _)| b.to_owned()).collect();
let body = format!(
r#"<h1>Result</h1>
<div class="kv">
<div class="card"><span class="dim">score</span><b>{score} <span class="dim">/ {max}</span></b></div>
<div class="card"><span class="dim">percentage</span><b>{pct}%</b></div>
<div class="card"><span class="dim">accuracy</span><b class="ok-t">{acc}%</b></div>
<div class="card"><span class="dim">time used</span><b>{dur}</b></div>
</div>
<div class="grid2">
<div class="card table-wrap"><h3>by subject</h3><table><thead><tr><th>subject</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{subj}</tbody></table></div>
<div class="card table-wrap"><h3>by difficulty</h3><table><thead><tr><th>difficulty</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{diff}</tbody></table></div>
</div>
<h2>chapters that cost you marks</h2>
<div class="card table-wrap"><table><thead><tr><th>subtopic</th><th>right</th><th>wrong</th><th>blank</th><th></th><th>%</th><th></th></tr></thead><tbody>{topic}</tbody></table></div>
<h2>every question</h2>
{detail}
<p style="margin-top:18px;display:flex;gap:8px;flex-wrap:wrap">
<a class="btn" href="{b}/history">all papers</a>
<a class="btn" href="{b}/dashboard">dashboard</a>
<a class="btn primary" href="{b}/build">new paper</a>
</p>"#,
score = a.score,
max = a.max_score,
pct = fmt_pct(a.score, a.max_score),
acc = accuracy(a.right, a.wrong),
dur = fmt_dur(a.used_secs),
subj = bucket_rows(&a.by_subject),
diff = bucket_rows(&a.by_difficulty),
topic = bucket_rows(&topic_buckets),
detail = detail,
b = ctx.base
);
shell(ctx, Some(user), "Result", body)
}
pub fn history(ctx: &Ctx, user: &str, tests: &[TestRow]) -> String {
let rows: String = if tests.is_empty() {
String::new()
} else {
tests
.iter()
.map(|t| {
format!(
r#"<tr><td>{d}</td><td>{title}</td><td>{score} / {max}</td><td>{p}%</td><td>{acc}%</td><td>{dur}</td><td><a href="{b}/result/{id}">open</a></td></tr>"#,
d = t.started_at.format("%d %b %Y"),
title = esc(&t.title),
score = t.score,
max = t.max_score,
p = fmt_pct(t.score, t.max_score),
acc = accuracy(t.right, t.wrong),
dur = fmt_dur(t.used_secs),
b = ctx.base,
id = esc(&t.attempt_id)
)
})
.collect()
};
let body = if tests.is_empty() {
format!(
r#"<h1>History</h1><div class="card" style="margin-top:16px;max-width:60ch"><h3>No papers yet</h3><p class="meta">Nothing is here because no paper has been submitted. Build one and it appears here with its score and accuracy.</p><p style="margin-top:12px"><a class="btn primary" href="{b}/build">build your first paper</a></p></div>"#,
b = ctx.base
)
} else {
format!(
r#"<h1>History</h1><div class="card table-wrap" style="margin-top:16px"><table><thead><tr><th>date</th><th>paper</th><th>score</th><th>%</th><th>accuracy</th><th>time</th><th></th></tr></thead><tbody>{rows}</tbody></table></div>"#,
rows = rows
)
};
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 {
let (taken, marks, gained, lost) = totals;
let trend: String = if tests.is_empty() {
String::new()
} else {
let best = tests.iter().map(|t| t.score * 100 / t.max_score.max(1)).max().unwrap_or(0);
let mut s = String::from(r#"<h2>percentage across papers</h2><div class="card table-wrap"><table><thead><tr><th>date</th><th>paper</th><th>%</th><th>accuracy</th></tr></thead><tbody>"#);
for t in tests {
s.push_str(&format!(
r#"<tr><td>{}</td><td>{}</td><td>{}%</td><td>{}%</td></tr>"#,
t.started_at.format("%d %b"),
esc(&t.title),
fmt_pct(t.score, t.max_score),
accuracy(t.right, t.wrong)
));
}
s.push_str("</tbody></table></div>");
s = s.replace("{best}", &best.to_string());
s
};
let weak_rows: String = weak
.iter()
.map(|(b, chapter, subtopic)| {
format!(
r#"<tr><td>{sub} <span class="dim">· {ch}</span></td><td>{t}</td><td>{w}</td><td>{s}</td><td>{seg}</td><td class="{st}">{st}</td></tr>"#,
sub = esc(subtopic),
ch = esc(chapter),
t = b.total(),
w = b.wrong,
s = b.skipped,
seg = seg(b),
st = b.status()
)
})
.collect();
let notes_html: String = notes.iter().map(|n| format!("<li>{}</li>", esc(n))).collect();
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>"#,
b = ctx.base
)
} else {
format!(
r#"<h1>Dashboard</h1>
<div class="kv">
<div class="card"><span class="dim">papers submitted</span><b>{taken}</b></div>
<div class="card"><span class="dim">marks scored</span><b>{marks}</b></div>
<div class="card"><span class="dim">correct</span><b class="ok-t">{gained}</b></div>
<div class="card"><span class="dim">wrong</span><b class="bad-t">{lost}</b></div>
</div>
{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>
<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>"#,
taken = taken,
marks = marks,
gained = gained,
lost = lost,
trend = trend,
weak = weak_rows,
notes = notes_html,
b = ctx.base
)
};
shell(ctx, Some(user), "Dashboard", body)
}
pub fn shared(ctx: &Ctx, title: &str, duration: i64, questions: &[Question]) -> String {
let qs: String = questions
.iter()
.enumerate()
.map(|(i, q)| {
let opts = if q.qtype == "NUM" {
String::new()
} else {
["A", "B", "C", "D"]
.iter()
.enumerate()
.map(|(k, l)| format!("<p><b>{l}</b> {}</p>", esc(&q.opts[k])))
.collect()
};
format!(
r#"<details class="card" style="margin-bottom:8px"><summary style="cursor:pointer">q{n} · {subj} · {sub2}</summary><p style="margin:12px 0 8px;color:#fff">{stem}</p>{opts}</details>"#,
n = i + 1,
subj = esc(&q.subject),
sub2 = esc(&q.subtopic),
stem = esc(&q.stem),
opts = opts
)
})
.collect();
let body = format!(
r#"<h1>{title}</h1><p class="meta">{count} questions · {dur} minutes · no answers or scores on this page.</p><div style="margin-top:16px">{qs}</div>"#,
title = esc(title),
count = questions.len(),
dur = duration,
qs = qs
);
shell(ctx, None, title, body)
}