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:
parent
14542f169a
commit
1d8ee3b5ff
6 changed files with 334 additions and 106 deletions
|
|
@ -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)
|
||||
|
|
|
|||
121
src/exam.rs
121
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<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))
|
||||
}
|
||||
|
|
|
|||
61
src/main.rs
61
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<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()
|
||||
|
|
|
|||
160
src/views.rs
160
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<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(""),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue