diff --git a/scripts/audit_mobile.py b/scripts/audit_mobile.py index 028a870..ce8dc15 100755 --- a/scripts/audit_mobile.py +++ b/scripts/audit_mobile.py @@ -34,10 +34,10 @@ AUDIT = """ .map(el => el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).split(' ')[0] : '')); // Touch targets on the controls a thumb has to hit. A checkbox is measured // through its wrapping label, which is the box a thumb actually lands on. - const TARGETS = 'button, a.btn, .pal, .opt, .tabbar a, input, select, label.chk'; + const TARGETS = 'button, a.btn, .pal, .opt, .tabbar a, input, select, label.chk, label.sub-chk'; const small = [...document.querySelectorAll(TARGETS)] .map(el => { - const box = (el.matches('input') && el.closest('label.chk')) ? el.closest('label.chk') : el; + const box = (el.matches('input') && el.closest('label')) ? el.closest('label') : el; return { el: box, r: box.getBoundingClientRect() }; }) .filter(x => x.r.width > 0 && x.r.height > 0) diff --git a/src/exam.rs b/src/exam.rs index be3d9b4..c92e734 100644 --- a/src/exam.rs +++ b/src/exam.rs @@ -41,21 +41,33 @@ pub fn qrow(r: &sqlx::sqlite::SqliteRow) -> Question { } } -/// `chapters` arrives comma-separated: a urlencoded body with repeated `chapters=1&chapters=4` -/// keys keeps only the last value, so the form posts one list field instead. +/// Both `chapters` and `subtopics` arrive comma-separated (repeated keys lose all +/// but the last value in urlencoded bodies, so they are joined into one field each). #[derive(Deserialize)] pub struct BuildForm { #[serde(default)] pub chapters: String, + /// When non-empty, only questions from these subtopic ids are drawn. + #[serde(default)] + pub subtopics: String, pub count: Option, pub duration: Option, pub unseen_only: Option, + /// "jee" means 3 subjects × 60 min each; otherwise one flat timer. + #[serde(default)] + pub mode: String, } impl BuildForm { - fn chapter_ids(&self) -> Vec { + pub fn chapter_ids(&self) -> Vec { self.chapters.split(',').filter_map(|c| c.trim().parse().ok()).collect() } + pub fn subtopic_ids(&self) -> Vec { + self.subtopics.split(',').filter_map(|c| c.trim().parse().ok()).collect() + } + pub fn is_jee_mode(&self) -> bool { + self.mode == "jee" + } } pub async fn build_paper(pool: &SqlitePool, uid: &str, f: &BuildForm) -> anyhow::Result<(String, String)> { @@ -63,58 +75,99 @@ pub async fn build_paper(pool: &SqlitePool, uid: &str, f: &BuildForm) -> anyhow: if chapters.is_empty() { anyhow::bail!("Pick at least one chapter"); } - let count = f.count.unwrap_or(12).clamp(3, 45); - let duration = f.duration.unwrap_or(count as i64 * 2).clamp(5, 180); + let subtopics = f.subtopic_ids(); + let count = f.count.unwrap_or(12).clamp(3, 75); let unseen = f.unseen_only.as_deref() == Some("on"); + // JEE Main: each subject gets 60 minutes. + // JEE mode: 60 min per unique subject across the selected chapters. + // Custom: caller picks a flat duration; default is 2 min/question. + let duration = if f.is_jee_mode() { + let mut subj_set = std::collections::HashSet::::new(); + for ch in &chapters { + if let Ok(Some(s)) = sqlx::query_scalar::<_, String>("SELECT subject FROM chapters WHERE id=?") + .bind(ch).fetch_optional(pool).await { subj_set.insert(s); } + } + 60 * subj_set.len().max(1) as i64 + } else { + f.duration.unwrap_or(count as i64 * 2).clamp(5, 180) + }; + let mut ids: Vec = Vec::new(); for ch in &chapters { - // sqlx requires a static SQL string for queries that bind parameters; unseen-exclusion is a separate branch. - let rows = if unseen { + // Four branches: unseen×subtopic filter vs not. + // sqlx needs a static query string per branch. + let rows: Vec = if !subtopics.is_empty() && unseen { sqlx::query( - "SELECT q.id FROM questions q WHERE q.chapter_id=?\n AND q.id NOT IN (SELECT question_id FROM seen_questions WHERE user_id=?)", + "SELECT q.id FROM questions q WHERE q.chapter_id=? AND q.subtopic_id=? AND q.id NOT IN (SELECT question_id FROM seen_questions WHERE user_id=?)" ) - .bind(ch) - .bind(uid) - .fetch_all(pool) - .await? + // For each chapter, collect questions from matching subtopics. + .bind(ch).bind(0i64).bind(uid) // placeholder; handled per-subtopic below + .fetch_all(pool).await.unwrap_or_default() + } else { vec![] }; + let _ = rows; // handled below with proper per-subtopic loop + + if !subtopics.is_empty() { + for st in &subtopics { + let rows: Vec = if unseen { + sqlx::query( + "SELECT q.id FROM questions q WHERE q.chapter_id=? AND q.subtopic_id=? AND q.id NOT IN (SELECT question_id FROM seen_questions WHERE user_id=?)" + ).bind(ch).bind(st).bind(uid).fetch_all(pool).await? + } else { + sqlx::query( + "SELECT q.id FROM questions q WHERE q.chapter_id=? AND q.subtopic_id=?" + ).bind(ch).bind(st).fetch_all(pool).await? + }; + for r in rows { ids.push(r.get::("id")); } + } } else { - sqlx::query("SELECT q.id FROM questions q WHERE q.chapter_id=?") - .bind(ch) - .fetch_all(pool) - .await? - }; - for r in rows { - ids.push(r.get::("id")); + let rows: Vec = if unseen { + sqlx::query( + "SELECT q.id FROM questions q WHERE q.chapter_id=? AND q.id NOT IN (SELECT question_id FROM seen_questions WHERE user_id=?)" + ).bind(ch).bind(uid).fetch_all(pool).await? + } else { + sqlx::query("SELECT q.id FROM questions q WHERE q.chapter_id=?") + .bind(ch).fetch_all(pool).await? + }; + for r in rows { ids.push(r.get::("id")); } } } + // Deduplicate: a question can match multiple subtopics if the form sent both. + ids.sort_unstable(); + ids.dedup(); if ids.is_empty() { anyhow::bail!("No questions left for that selection. Turn off 'unseen only' to reuse questions."); } - // Deterministic shuffle keyed on the request, so a reload rebuilds an equivalent paper. let seed = uid.len() as u64 ^ chapters.iter().sum::() as u64 ^ count as u64; shuffle(&mut ids, seed); ids.truncate(count); let paper = uuid::Uuid::new_v4().to_string(); let share = uuid::Uuid::new_v4().simple().to_string()[..10].to_string(); - let title = format!("Mixed paper · {count} questions"); + // Derive a title: fetch the subject for each chapter that contributed questions. + let chapter_subjects: std::collections::HashSet = { + let mut hs = std::collections::HashSet::new(); + for ch in &chapters { + let subj: Option = sqlx::query_scalar("SELECT subject FROM chapters WHERE id=?") + .bind(ch).fetch_optional(pool).await.unwrap_or_default(); + if let Some(s) = subj { hs.insert(s); } + } + hs + }; + let title = if chapter_subjects.len() == 1 { + chapter_subjects.iter().next().map(|s| format!("{s} paper · {count} questions")).unwrap_or_else(|| format!("Mixed paper · {count} questions")) + } else { + format!("Mixed paper · {count} questions") + }; + sqlx::query("INSERT INTO papers(id,user_id,title,duration_min,created_at,share_token) VALUES(?,?,?,?,?,?)") - .bind(&paper) - .bind(uid) - .bind(&title) - .bind(duration) - .bind(Utc::now().to_rfc3339()) - .bind(&share) - .execute(pool) - .await?; + .bind(&paper).bind(uid).bind(&title).bind(duration) + .bind(Utc::now().to_rfc3339()).bind(&share) + .execute(pool).await?; for (i, qid) in ids.iter().enumerate() { sqlx::query("INSERT INTO paper_questions(paper_id,question_id,ord) VALUES(?,?,?)") - .bind(&paper) - .bind(qid) - .bind(i as i64) - .execute(pool) - .await?; + .bind(&paper).bind(qid).bind(i as i64) + .execute(pool).await?; } Ok((paper, share)) } diff --git a/src/main.rs b/src/main.rs index 02f796e..402b153 100644 --- a/src/main.rs +++ b/src/main.rs @@ -251,17 +251,12 @@ async fn sign_in_with_google(ctx: &CtxArc, claims: &google::Claims, next: Option auth::start_session(ctx, &uid, &name, &email, next).await } -async fn build_page(State(ctx): State, h: HeaderMap) -> axum::response::Response { - let (_uid, name) = match ctx.require_user(&h).await { - Ok(v) => v, - Err(r) => return r, - }; +async fn load_chapter_blocks(pool: &sqlx::SqlitePool) -> Vec { use sqlx::Row; let rows = sqlx::query( - "SELECT c.id, c.subject, c.name, s.name AS sub, (SELECT COUNT(*) FROM questions q WHERE q.chapter_id=c.id) AS n - FROM chapters c JOIN subtopics s ON s.chapter_id=c.id ORDER BY c.subject, c.name, s.name", + "SELECT c.id, c.subject, c.name, s.id AS sub_id, s.name AS sub, (SELECT COUNT(*) FROM questions q WHERE q.chapter_id=c.id) AS n FROM chapters c JOIN subtopics s ON s.chapter_id=c.id ORDER BY c.subject, c.name, s.name", ) - .fetch_all(&ctx.pool) + .fetch_all(pool) .await .unwrap_or_default(); let mut blocks: Vec = Vec::new(); @@ -269,6 +264,7 @@ async fn build_page(State(ctx): State, h: HeaderMap) -> axum::response:: let subject: String = r.get("subject"); let id: i64 = r.get("id"); let cname: String = r.get("name"); + let sub_id: i64 = r.get("sub_id"); let sub: String = r.get("sub"); let n: i64 = r.get("n"); if !blocks.iter().any(|b| b.subject == subject) { @@ -276,15 +272,24 @@ async fn build_page(State(ctx): State, h: HeaderMap) -> axum::response:: } let blk = blocks.iter_mut().find(|b| b.subject == subject).unwrap(); match blk.chapters.iter_mut().find(|c| c.id == id) { - Some(c) => { - c.subtopics.push(sub); - c.count = n; - } - None => blk.chapters.push(views::ChapterBlock { id, name: cname, subtopics: vec![sub], count: n }), + Some(c) => { c.subtopic_ids.push(sub_id); c.subtopics.push(sub); c.count = n; } + None => blk.chapters.push(views::ChapterBlock { + id, name: cname, subtopic_ids: vec![sub_id], subtopics: vec![sub], count: n, + }), } } + blocks +} + + +async fn build_page(State(ctx): State, h: HeaderMap) -> axum::response::Response { + let (_uid, name) = match ctx.require_user(&h).await { + Ok(v) => v, + Err(r) => return r, + }; + let blocks = load_chapter_blocks(&ctx.pool).await; let available: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM questions").fetch_one(&ctx.pool).await.unwrap_or(0); - let form = BuildForm { chapters: String::new(), count: Some(12), duration: Some(24), unseen_only: Some("on".into()) }; + let form = BuildForm { chapters: String::new(), subtopics: String::new(), count: Some(12), duration: Some(24), unseen_only: Some("on".into()), mode: String::new() }; hx(views::build(&ctx, &name, blocks, &form, available, None), StatusCode::OK).into_response() } @@ -296,33 +301,7 @@ async fn build_post(State(ctx): State, h: HeaderMap, Form(f): Form Redirect::to(&format!("{}/attempt/{paper}", ctx.base)).into_response(), Err(e) => { - use sqlx::Row; - let rows = sqlx::query( - "SELECT c.id, c.subject, c.name, s.name AS sub, (SELECT COUNT(*) FROM questions q WHERE q.chapter_id=c.id) AS n - FROM chapters c JOIN subtopics s ON s.chapter_id=c.id ORDER BY c.subject, c.name, s.name", - ) - .fetch_all(&ctx.pool) - .await - .unwrap_or_default(); - let mut blocks: Vec = Vec::new(); - for r in &rows { - let subject: String = r.get("subject"); - let id: i64 = r.get("id"); - let cname: String = r.get("name"); - let sub: String = r.get("sub"); - let n: i64 = r.get("n"); - if !blocks.iter().any(|b| b.subject == subject) { - blocks.push(views::SubjectBlock { subject: subject.clone(), chapters: Vec::new() }); - } - let blk = blocks.iter_mut().find(|b| b.subject == subject).unwrap(); - match blk.chapters.iter_mut().find(|c| c.id == id) { - Some(c) => { - c.subtopics.push(sub); - c.count = n; - } - None => blk.chapters.push(views::ChapterBlock { id, name: cname, subtopics: vec![sub], count: n }), - } - } + let blocks = load_chapter_blocks(&ctx.pool).await; let available: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM questions").fetch_one(&ctx.pool).await.unwrap_or(0); let msg = e.to_string(); hx(views::build(&ctx, &_name, blocks, &f, available, Some(&msg)), StatusCode::OK).into_response() diff --git a/src/views.rs b/src/views.rs index b1a316b..51b7ae8 100644 --- a/src/views.rs +++ b/src/views.rs @@ -17,9 +17,16 @@ pub fn esc(s: &str) -> String { 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, } @@ -268,54 +275,128 @@ pub fn error(ctx: &Ctx, code: u16, detail: &str) -> String { 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!("

{}

", esc(&sb.subject))); + picker.push_str(&format!(r#"

"#, + s = esc(&sb.subject), sl = esc(&sb.subject))); for ch in &sb.chapters { - let subs = ch.subtopics.iter().map(|s| esc(s)).collect::>().join(", "); + // 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#""#, - cid = ch.id, - cname = esc(&ch.name), - subs = subs, - count = ch.count + 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 match this bank. Pick the chapters you are revising; the paper is drawn from them and marked on the +4 / −1 scheme.

-
+

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

+ -
{picker}
-
-
+ + +
{picker}
+
+

paper settings

- - - -

no chapters picked yet

+ +
+ + +
+ +

no topics picked yet

{e} -
+
-"#, +}})(); +"#, available = available, b = ctx.base, picker = picker, @@ -409,6 +490,27 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], ) }) .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#"
@@ -416,6 +518,7 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question],
time left
{dur}
+ {stbar}
@@ -440,6 +543,7 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question], 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(""), diff --git a/static/exam.js b/static/exam.js index f061162..93e336c 100644 --- a/static/exam.js +++ b/static/exam.js @@ -36,6 +36,7 @@ }; } + window._examShow = show; function show(i) { cur = Math.max(0, Math.min(panes.length - 1, i)); panes.forEach((p, k) => { p.hidden = k !== cur; }); @@ -209,4 +210,65 @@ const hash = Number((location.hash.match(/q(\d+)/) || [])[1]); panes.forEach(syncPalette); show(hash ? hash - 1 : 0); -})(); \ No newline at end of file +})(); +// Per-subject timers — only active in JEE mode (duration == 60×nsubjects). +// Each strip reads its initial seconds from data-secs and counts down. +// When a strip hits zero the exam auto-advances to the next subject, not the +// whole paper; only the last strip exhausting time submits the form. +(() => { + const strips = [...document.querySelectorAll('.subj-timer')]; + if (!strips.length) return; + + const form = document.getElementById('exam-form'); + const panes = [...document.querySelectorAll('.qpane')]; + let currentStrip = 0; + + // Helper: first pane index that belongs to a given subject name. + function firstPaneForSubj(name) { + return panes.findIndex(p => p.querySelector('.pane-head .dim')?.textContent.includes(name)); + } + // Helper: first pane index that does NOT belong to a given subject. + function firstPaneAfterSubj(name) { + return panes.findIndex(p => !p.querySelector('.pane-head .dim')?.textContent.includes(name)); + } + + function paintStrip(el, left) { + const m = Math.floor(left / 60), s = left % 60; + el.querySelector('b').textContent = `${m}:${String(s).padStart(2,'0')}`; + el.classList.toggle('warn', left <= 300 && left > 0); + el.classList.toggle('active', left > 0); + el.classList.toggle('done', left <= 0); + } + + function runStrip(idx) { + const el = strips[idx]; + if (!el) { form.requestSubmit(); return; } + el.classList.add('active'); + let left = Number(el.dataset.secs); + const tick = setInterval(() => { + left -= 1; + el.dataset.secs = left; + paintStrip(el, left); + if (left <= 0) { + clearInterval(tick); + el.classList.remove('active'); + el.classList.add('done'); + currentStrip = idx + 1; + if (currentStrip < strips.length) { + // Auto-jump to the first unanswered question of the next subject. + const nextSubj = strips[currentStrip]?.dataset.subj; + const pi = nextSubj ? firstPaneForSubj(nextSubj) : -1; + if (pi >= 0 && typeof window._examShow === 'function') window._examShow(pi); + runStrip(currentStrip); + } else { + form.requestSubmit(); + } + } + }, 1000); + } + + // Expose the show function from the main IIFE so the subject timer can jump. + // exam.js main IIFE sets window._examShow after calling show(). + strips.forEach((el, i) => paintStrip(el, Number(el.dataset.secs))); + runStrip(0); +})(); diff --git a/static/style.css b/static/style.css index 17b6287..9c41d59 100644 --- a/static/style.css +++ b/static/style.css @@ -197,4 +197,34 @@ input[type=text],input[type=email],input[type=number],select{min-height:44px;pad /* The native checkbox is small by design, so the label around it carries the target. */ .chk{min-height:44px} .chk input[type=checkbox]{width:20px;height:20px;margin-top:2px;flex:none} +.opt{min-height:44px} .opt input[type=radio]{width:20px;height:20px;flex:none} + +/* ===== builder: subtopic picker ===== + Chapters are the primary unit; subtopics expand inside them. */ +.subject-section{margin-bottom:20px} +.subject-label{font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;margin:0 0 8px} +.chapter-group{margin-bottom:8px} +.chapter-hd{display:flex;align-items:center;min-height:44px;background:var(--panel);padding:10px 12px;border:1px solid var(--border)} +.chapter-hd b{color:#fff} +.subtopic-list{padding:4px 12px 4px 32px;border:1px solid var(--border);border-top:0;display:none} +.chapter-group.expanded .subtopic-list{display:block} +.sub-chk{min-height:38px;padding:4px 0} +.settings-sticky{position:sticky;top:72px} +.picker-col,.settings-col{min-width:0} +.build-note{font-variant-numeric:tabular-nums} +.all-subj input[type=checkbox]{accent-color:var(--cyan)} +@media(max-width:700px){ + .settings-sticky{position:static} + .chapter-hd{background:none;padding:10px 0} + .subtopic-list{padding:4px 0 4px 20px} +} + +/* ===== per-subject timers on exam screen ===== + Each strip shows for its subject window and hides when that window closes. */ +.subj-timer-bar{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px} +.subj-timer{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border:1px solid var(--border);font-size:.8rem} +.subj-timer b{color:#fff;min-width:5ch;font-variant-numeric:tabular-nums} +.subj-timer.active{border-color:var(--cyan)} +.subj-timer.warn{border-color:var(--amber)} +.subj-timer.done{opacity:.4;text-decoration:line-through}