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("<"), '>' => o.push_str(">"), '"' => o.push_str("""), '\'' => o.push_str("'"), _ => 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, pub subtopics: Vec, pub count: i64, } pub struct SubjectBlock { pub subject: String, pub chapters: Vec, } fn head(ctx: &Ctx, title: &str) -> String { format!( r#" {t} · PYQ "#, t = esc(title), b = ctx.base ) } fn nav(ctx: &Ctx, user: Option<&str>) -> String { let brand = format!( r#"
PYQjee main practice
{tabbar}") } fn foot(ctx: &Ctx) -> String { format!( r#""#, 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("", "", 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#"build a paper"#, b = ctx.base) } else { format!(r#"start a paper"#, b = ctx.base) }; let body = format!( r#"

JEE Main practice, built like the real exam screen.

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.

questions{q}
shifts{shifts}
subtopics{subtopics}
students{students}

Build

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.

Attempt

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.

Analysis

Score, accuracy, time per question, and results broken down by subject, chapter and difficulty. Solutions sit under every question.

Progress

Every submitted paper is kept. The dashboard tracks percentage and accuracy across tests and names the subtopics costing you the most marks.

{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#""#)).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#"

loading the Google button…

"#, 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#"

Sign in

{intro}

{google_block}

{action}

{e}
{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#" "#, 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#"

{t}

{b}
"#, 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#"

{t}

{d}

back to home

"#, t = esc(&t), d = esc(&d), b = ctx.base ); shell(ctx, None, &t, body) } pub fn build(ctx: &Ctx, user: &str, blocks: Vec, _form: &BuildForm, available: i64, err: Option<&str>) -> String { let mut picker = String::new(); for sb in &blocks { picker.push_str(&format!(r#"

"#, 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#""#, sid = sid, cid = ch.id, sn = esc(sname)) }).collect(); picker.push_str(&format!( r#"
{subcheck}
"#, cid = ch.id, cname = esc(&ch.name), count = ch.count, subj = esc(&sb.subject), subcheck = subcheck )); } picker.push_str("
"); } let e = err.map(|m| format!(r#"

{m}

"#)).unwrap_or_default(); let body = format!( r#"

Build a paper

{available} questions in the bank. Pick chapters or individual subtopics.

{picker}

paper settings

no topics picked yet

{e}
"#, 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#""#, cls = cls, n = i + 1) }) .collect::>() .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 = 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 = if q.qtype == "NUM" { vec![format!( r#""#, 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#""#, cls = if sel.trim().eq_ignore_ascii_case(letter) { " sel" } else { "" }, id = q.id, letter = letter, checked = checked, t = esc(&text) ) }) .collect() }; format!( r#"
question {n} of {total} · {subj} {qt} · +4 / −1{revflag}

{stem}

{sub2} · {ch} · {year} {shift} · {difficulty}
{opts}
"#, 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 { " · marked" } 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#"
{s}{m}:00
"#, cls = cls, s = esc(subj), secs = secs, i = i, m = 60) }).collect(); format!(r#"
{strips}
"#) } else { String::new() }; let body = format!( r#"
{title}{n} questions
time left
{dur}
{stbar}
{qs}
"#, 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#"
"#, 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#"{l}{r}{w}{s}{seg}{p}%{st}"#, 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#"

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 = esc(&r.q.opts[k]), mark = if is_ans { " (correct)" } else if is_sel { " (you)" } else { "" }) }) .collect() }; format!( r#"
q{n} · {subj} · {state} · {marks:+} marks

{stem}

{opts}

{sub2} · {ch} · {year} {shift} · {difficulty} · {qtime}

{sol}

"#, 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 = a.by_topic.iter().map(|(b, _)| b.to_owned()).collect(); let body = format!( r#"

Result

score{score} / {max}
percentage{pct}%
accuracy{acc}%
time used{dur}

by subject

{subj}
subjectrightwrongblank%

by difficulty

{diff}
difficultyrightwrongblank%

chapters that cost you marks

{topic}
subtopicrightwrongblank%

every question

{detail}

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#"{d}{title}{score} / {max}{p}%{acc}%{dur}open"#, 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#"

History

No papers yet

Nothing is here because no paper has been submitted. Build one and it appears here with its score and accuracy.

build your first paper

"#, b = ctx.base ) } else { format!( r#"

History

{rows}
datepaperscore%accuracytime
"#, 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#"

percentage across papers

"#); for t in tests { s.push_str(&format!( r#""#, t.started_at.format("%d %b"), esc(&t.title), fmt_pct(t.score, t.max_score), accuracy(t.right, t.wrong) )); } s.push_str("
datepaper%accuracy
{}{}{}%{}%
"); s = s.replace("{best}", &best.to_string()); s }; let weak_rows: String = weak .iter() .map(|(b, chapter, subtopic)| { format!( r#"{sub} · {ch}{t}{w}{s}{seg}{st}"#, 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!("
  • {}
  • ", esc(n))).collect(); let body = if tests.is_empty() { format!( r#"

    Dashboard

    No test data yet

    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.

    build a paper

    "#, b = ctx.base ) } else { format!( r#"

    Dashboard

    papers submitted{taken}
    marks scored{marks}
    correct{gained}
    wrong{lost}
    {trend}

    weakest subtopics across all papers

    {weak}
    subtopicseenwrongblank

    coach notes

      {notes}

    Written from your own submitted papers. No data leaves this machine.

    all papers

    "#, 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!("

    {l} {}

    ", esc(&q.opts[k]))) .collect() }; format!( r#"
    q{n} · {subj} · {sub2}

    {stem}

    {opts}
    "#, n = i + 1, subj = esc(&q.subject), sub2 = esc(&q.subtopic), stem = esc(&q.stem), opts = opts ) }) .collect(); let body = format!( r#"

    {title}

    {count} questions · {dur} minutes · no answers or scores on this page.

    {qs}
    "#, title = esc(title), count = questions.len(), dur = duration, qs = qs ); shell(ctx, None, title, body) }