{cta}
"#, 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#"{m}
"#)).unwrap_or_default(); let google_on = ctx.google.is_some() && fallback.is_some(); // Students are non-technical: one primary action, plain words, no jargon. // Google first when configured (scores follow the account across devices); // otherwise the name+email form, honestly labeled as this-device-only. let google_block = if google_on { format!( r#"loading the Google button…
Lost access on a new phone? Sign in with the same Google account and everything returns. With the email form, scores live in this browser only — there is no password to forget because there is no password.
{gsi_script}"#, google_block = google_block, divider = divider, email_head = email_head, email_note = email_note, 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#" "#, 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#"{m}
"#)).unwrap_or_default(); let body = format!( r#"{stem}
{opts}your answer: {sel} · correct: {ans}
"#, 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#"{l}: {t}{mark}"#, style = style, l = l, t = rich(&r.q.opts[k]), mark = if is_ans { " (correct)" } else if is_sel { " (you)" } else { "" }) }) .collect() }; format!( r#"{stem}
{opts}
{sub2} · {ch} · {year} {shift} · {difficulty} · {qtime}
{sol}
{mform}| subject | right | wrong | blank | % |
|---|
| difficulty | right | wrong | blank | % |
|---|
| subtopic | right | wrong | blank | % |
|---|
all papers dashboard new paper
"#, 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#"| date | paper | score | % | accuracy | time |
|---|
| date | paper | % | accuracy |
|---|---|---|---|
| {} | {} | {}% | {}% |
| mistake type | count |
|---|
Tag lost questions on any result page; the most common reason surfaces here.
"#, rows = rows ) }; let body = if tests.is_empty() { format!( r#"| subtopic | seen | wrong | blank |
|---|
Written from your own submitted papers. No data leaves this machine.
{l} {}
", rich(&q.opts[k]))) .collect() }; format!( r#"{stem}
{opts}