Subtopic picker, JEE mode, per-subject timers

The builder previously exposed only chapters. Now each chapter expands to show
its subtopics as individual checkboxes: selecting a chapter checks all of its
subtopics, selecting the subject-level box checks everything, and selecting
individual subtopics sets the chapter to indeterminate. The exam query filters
by subtopic_id when any are given; the SQL path deduplicates so a question
matching two subtopics only appears once.

JEE mode sets duration to 60 × unique-subject-count (180 min for a full paper).
On the exam screen this renders per-subject countdown strips: Physics 60:00,
Maths 60:00, Chemistry 60:00. Each strip counts down independently; when one
hits zero the screen auto-jumps to the first unanswered question of the next
subject. The last strip exhausting time submits the form.

Single-subject papers keep the flat timer and show no strips.

Also: all audit targets now genuinely 44px (the .opt labels were 42px).
This commit is contained in:
dev 2026-10-12 05:13:09 +05:30
parent 14542f169a
commit 1d8ee3b5ff
6 changed files with 334 additions and 106 deletions

View file

@ -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)

View file

@ -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<usize>,
pub duration: Option<i64>,
pub unseen_only: Option<String>,
/// "jee" means 3 subjects × 60 min each; otherwise one flat timer.
#[serde(default)]
pub mode: String,
}
impl BuildForm {
fn chapter_ids(&self) -> Vec<i64> {
pub fn chapter_ids(&self) -> Vec<i64> {
self.chapters.split(',').filter_map(|c| c.trim().parse().ok()).collect()
}
pub fn subtopic_ids(&self) -> Vec<i64> {
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::<String>::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<i64> = 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<sqlx::sqlite::SqliteRow> = 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<sqlx::sqlite::SqliteRow> = 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::<i64, _>("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::<i64, _>("id"));
let rows: Vec<sqlx::sqlite::SqliteRow> = 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::<i64, _>("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::<i64>() 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<String> = {
let mut hs = std::collections::HashSet::new();
for ch in &chapters {
let subj: Option<String> = 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))
}

View file

@ -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<CtxArc>, 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<views::SubjectBlock> {
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<views::SubjectBlock> = Vec::new();
@ -269,6 +264,7 @@ async fn build_page(State(ctx): State<CtxArc>, 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<CtxArc>, 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<CtxArc>, 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<CtxArc>, h: HeaderMap, Form(f): Form<Build
match exam::build_paper(&ctx.pool, &uid, &f).await {
Ok((paper, _share)) => 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<views::SubjectBlock> = 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()

View file

@ -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<i64>,
pub subtopics: Vec<String>,
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<SubjectBlock>, _form: &BuildForm, available: i64, err: Option<&str>) -> String {
let mut picker = String::new();
for sb in &blocks {
picker.push_str(&format!("<h3>{}</h3>", esc(&sb.subject)));
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 {
let subs = ch.subtopics.iter().map(|s| esc(s)).collect::<Vec<_>>().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#"<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#"<label class="chk"><input type="checkbox" class="chap" value="{cid}"><span><b>{cname}</b><br><span class="dim">{subs} · {count} questions</span></span></label>"#,
cid = ch.id,
cname = esc(&ch.name),
subs = subs,
count = ch.count
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 match this bank. Pick the chapters you are revising; the paper is drawn from them and marked on the +4 / −1 scheme.</p>
<form method="post" action="{b}/build" style="margin-top:18px">
<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="">
<div class="grid2"><div>{picker}</div>
<div>
<div style="position:sticky;top:64px">
<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 · short drill</option><option value="12" selected>12</option>
<option value="18">18</option><option value="25">25</option><option value="36">36 · full paper</option>
<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 for="duration">duration in minutes</label>
<input id="duration" name="duration" type="number" value="24" min="5" max="180">
<label class="chk" style="margin-top:14px"><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" id="pick-note" style="margin-top:10px">no chapters picked yet</p>
<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" type="submit">create paper</button></div>
<div style="margin-top:14px"><button class="btn primary block" type="submit">create paper</button></div>
</div>
</div></div>
</form>
<script>// Repeated checkbox keys do not survive a urlencoded body, so the ids are joined into one field.
<script>
(() => {{
const boxes = [...document.querySelectorAll('.chap')];
const field = document.getElementById('chapters');
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 sync = () => {{
field.value = boxes.filter(b => b.checked).map(b => b.value).join(',');
note.textContent = field.value ? field.value.split(',').length + ' chapters picked' : 'no chapters picked yet';
}};
boxes.forEach(b => b.addEventListener('change', sync));
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>"#,
}})();
</script>"#,
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#"<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">
@ -416,6 +518,7 @@ pub fn attempt(ctx: &Ctx, user: &str, attempt_id: &str, questions: &[Question],
<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">
@ -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(""),

View file

@ -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);
})();
})();
// 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);
})();

View file

@ -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}