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 } /// Render question body HTML stored in the DB. The importer sanitizes at write /// time; this re-sanitizes on read so a hand-edited row can never inject a /// script. Only the sanitizer's allowlist survives — see /// scripts/import_examgoal.py for the matching write-side list. pub fn rich(s: &str) -> String { const ALLOWED: &[&str] = &[ "br", "p", "sup", "sub", "b", "strong", "i", "em", "u", "ol", "ul", "li", "table", "tbody", "thead", "tr", "th", "td", "span", ]; let mut o = String::with_capacity(s.len()); let mut rest = s; while let Some(idx) = rest.find('<') { let (text, after) = rest.split_at(idx); for c in text.chars() { match c { '&' => o.push_str("&"), '>' => o.push_str(">"), other => o.push(other), } } if let Some(end) = after.find('>') { let inner = after[1..end].trim(); let (closing, name) = match inner.strip_prefix('/') { Some(r) => (true, r.split_whitespace().next().unwrap_or("")), None => (false, inner.split_whitespace().next().unwrap_or("")), }; let name = name.trim_end_matches('/').to_ascii_lowercase(); let has_attrs = inner.contains(char::is_whitespace) && !inner.ends_with('/'); if !has_attrs && ALLOWED.contains(&name.as_str()) { o.push('<'); if closing { o.push('/'); } o.push_str(&name); if !closing && inner.ends_with('/') { o.push('/'); } o.push('>'); } rest = &after[end + 1..]; } else { o.push_str("<"); rest = &after[1..]; } } for c in rest.chars() { match c { '&' => o.push_str("&"), '>' => o.push_str(">"), other => o.push(other), } } o } 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} · WhalePrep "##, t = esc(title), b = ctx.base, origin = public_origin() ) } /// The absolute origin for og:image URLs — meta tags need a full URL, not a /// relative path. Derived from the request when behind the tunnel; falls /// back to the production domain. fn public_origin() -> String { std::env::var("PUBLIC_ORIGIN").unwrap_or_else(|_| "https://whaleprep.oiupoyt.space".into()) } fn nav(ctx: &Ctx, user: Option<&str>) -> String { let brand = format!( r##"
{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 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#""#, b = ctx.base, next = esc(next) ) } else { String::new() }; let divider = if google_on { "
or
".to_string() } else { String::new() }; let email_head = if google_on { "use just a name and email" } else { "sign in" }; let email_note = if google_on { "No Google account? Type any name and email. Your papers stay on this browser only — use the same email on another device and the scores won't follow." } else { "Type any name and email to start practising. Your papers stay on this browser for 30 days — use the same email here and everything is still waiting." }; let body = format!( r#"

Sign in

One tap and your scores, mistakes and weak topics are saved.

{google_block} {divider}

{email_head}

{email_note}

{e}

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

{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 { let subj = &sb.subject; picker.push_str(&format!( r#"
"#, subj = esc(subj) )); for ch in &sb.chapters { let sub_count = ch.subtopics.len(); let subcheck: String = ch.subtopic_ids.iter().zip(ch.subtopics.iter()).map(|(sid, sname)| { format!( r#""#, sid = sid, cid = ch.id, subj = esc(subj), sn = esc(sname) ) }).collect(); picker.push_str(&format!( r#"
{sub_count} subtopics in {cname}
{subcheck}
"#, cid = ch.id, cname = esc(&ch.name), cname_lower = esc(&ch.name.to_lowercase()), count = ch.count, sub_count = sub_count, subj = esc(subj), subcheck = subcheck )); } picker.push_str("
"); } let e = err.map(|m| format!(r#""#)).unwrap_or_default(); let body = format!( r#"

Prepare a JEE Main test

Build a paper from the chapters you choose. {available} authentic JEE Main PYQs in the bank.

{picker}
DIFFICULTY
YEARS
{e}
0 chapters selected Pick chapters above
"#, 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 = rich(&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 = rich(&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, pub attempt_id: &'a str, pub tags: Vec, } 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#""#, v = v, cls = if on { " primary" } else { "" }, label = label ) }) .collect::>() .join(""); format!( r#"
why lost? {btns}
"#, base = base, attempt = attempt, qid = qid, btns = btns ) } 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 base = ctx.base.clone(); 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 = rich(&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}

{mform}
"#, n = i + 1, subj = esc(&r.q.subject), cls = cls, state = state, marks = r.marks, stem = rich(&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 = rich(&r.q.solution), mform = mistake_form(&base, r.attempt_id, r.q.id, state, &r.tags) ) }) .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), mistakes: &[(String, i64)]) -> 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 mistakes_html: String = if mistakes.is_empty() { String::new() } else { let total: i64 = mistakes.iter().map(|(_, n)| n).sum::().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#"{label}{n}
    {p}%"#, label = esc(label), n = n, p = (*n as f64 / total as f64 * 100.0).round() as i64 ) }) .collect(); format!( r#"

    why you lose marks

    {rows}
    mistake typecount

    Tag lost questions on any result page; the most common reason surfaces here.

    "#, rows = rows ) }; 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
    {mistakes_html}

    coach notes

      {notes}

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

    all papers

    "#, mistakes_html = mistakes_html, 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], paper_id: &str, token: &str, signed: bool) -> 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} {}

    ", rich(&q.opts[k]))) .collect() }; format!( r#"
    q{n} · {subj} · {sub2}
    {stem}
    {opts}
    "#, n = i + 1, subj = esc(&q.subject), sub2 = esc(&q.subtopic), stem = rich(&q.stem), opts = opts ) }) .collect(); let cta = if signed { format!( r#"
    "#, b = ctx.base, id = esc(paper_id) ) } else { format!( r#"

    sign in to take this paper

    "#, b = ctx.base, tok = esc(token) ) }; let body = format!( r#"

    {title}

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

    {cta}

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