From e4c51126a09593974452d7055fb9c03eebe00da7 Mon Sep 17 00:00:00 2001 From: spearzificial <342299391+spearzificial@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:42:40 +0530 Subject: [PATCH] Previous-year-paper mode: year+shift picker builds the full actual paper (exam.rs past branch, build-page mode cards wired) --- src/exam.rs | 64 ++++++++++++++++++++++++++++++++++++++++- src/main.rs | 2 ++ src/views.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++++++ static/style.css | 22 ++++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) diff --git a/src/exam.rs b/src/exam.rs index 36e7342..ce2d16c 100644 --- a/src/exam.rs +++ b/src/exam.rs @@ -62,6 +62,12 @@ pub struct BuildForm { /// "jee" means 3 subjects × 60 min each; otherwise one flat timer. #[serde(default)] pub mode: String, + /// When "1", take every question of the given year+shift (past-paper mode). + #[serde(default)] + pub past: String, + /// Shift filter for past-paper mode ("S1" or "S2"); "ALL" means any. + #[serde(default)] + pub shift: String, } impl BuildForm { @@ -87,9 +93,16 @@ impl BuildForm { pub fn is_jee_mode(&self) -> bool { self.mode == "jee" } + pub fn is_past_mode(&self) -> bool { + self.past == "1" + } } pub async fn build_paper(pool: &SqlitePool, uid: &str, f: &BuildForm) -> anyhow::Result<(String, String)> { + // Past-paper mode: every question of one year+shift, no chapter picking. + if f.is_past_mode() { + return build_past_paper(pool, uid, f).await; + } let chapters = f.chapter_ids(); if chapters.is_empty() { anyhow::bail!("Pick at least one chapter"); @@ -432,4 +445,53 @@ pub async fn finalize(pool: &SqlitePool, attempt: &str) -> anyhow::Result<(i32, } tx.commit().await?; Ok((score, max, used)) -} \ No newline at end of file +} + +/// Past-paper mode: take every question from the chosen year (and shift when +/// given), in a stable year-major order — mirrors sitting an actual paper. +async fn build_past_paper(pool: &SqlitePool, uid: &str, f: &BuildForm) -> anyhow::Result<(String, String)> { + let years = f.year_list(); + let year = match years.first() { + Some(y) => *y, + None => anyhow::bail!("Pick a year for the past paper"), + }; + let shift = f.shift.trim(); + let (sql, binds): (&str, Vec) = if shift.is_empty() || shift == "ALL" { + ("SELECT id FROM questions WHERE year=? ORDER BY subject, id", vec![year.to_string()]) + } else { + ("SELECT id FROM questions WHERE year=? AND shift=? ORDER BY subject, id", vec![year.to_string(), shift.to_string()]) + }; + let mut q = sqlx::query_scalar::<_, i64>(sql); + for b in &binds { q = q.bind(b); } + let mut ids: Vec = q.fetch_all(pool).await?; + if ids.is_empty() { + anyhow::bail!("No questions in the bank for that year and shift."); + } + ids.sort_unstable(); + ids.dedup(); + // Past papers are not shuffled: the real paper's order is part of the + // practice. Cap at 75 for the same reason as build mode. + ids.truncate(75); + + let paper = uuid::Uuid::new_v4().to_string(); + let share = uuid::Uuid::new_v4().simple().to_string()[..10].to_string(); + let title = if shift.is_empty() || shift == "ALL" { + format!("JEE Main {year}") + } else { + format!("JEE Main {year} {shift}") + }; + let mut tx = pool.begin().await?; + sqlx::query("INSERT INTO papers(id, user_id, title, duration_min, created_at, share_token) VALUES(?,?,?,?,?,?)") + .bind(&paper).bind(uid).bind(&title).bind(180).bind(chrono::Utc::now().to_rfc3339()).bind(&share) + .execute(&mut *tx).await?; + for (ord, qid) in ids.iter().enumerate() { + sqlx::query("INSERT INTO paper_questions(paper_id, question_id, ord) VALUES(?,?,?)") + .bind(&paper).bind(qid).bind(ord as i64) + .execute(&mut *tx).await?; + sqlx::query("INSERT OR REPLACE INTO seen_questions(user_id, question_id, seen_at) VALUES(?,?,?)") + .bind(uid).bind(qid).bind(chrono::Utc::now().to_rfc3339()) + .execute(&mut *tx).await?; + } + tx.commit().await?; + Ok((paper, share)) +} diff --git a/src/main.rs b/src/main.rs index 178cc45..ac3b1c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -315,6 +315,8 @@ async fn build_page(State(ctx): State, h: HeaderMap) -> axum::response:: duration: Some(30), unseen_only: Some("on".into()), mode: String::new(), + past: String::new(), + shift: String::new(), }; hx(views::build(&ctx, &name, blocks, &form, available, None), StatusCode::OK).into_response() } diff --git a/src/views.rs b/src/views.rs index 30aeb30..aec03af 100644 --- a/src/views.rs +++ b/src/views.rs @@ -496,6 +496,25 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec, _form: &BuildForm + + +

Selects every chapter in all three subjects. You can still remove any you don't want.

@@ -537,6 +556,8 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec, _form: &BuildForm + +
@@ -616,6 +637,47 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec, _form: &BuildForm const btnDesc = document.getElementById('btn-desc'); const searchInput = document.getElementById('zp-search-input'); + // Mode cards: build vs past-paper + const modeBuild = document.getElementById('mode-build'); + const modePast = document.getElementById('mode-past'); + const pastPanel = document.getElementById('zp-past-panel'); + const pastField = document.getElementById('past'); + const shiftField = document.getElementById('shift'); + const chaptersWrap = document.querySelector('.zp-two-col'); + let pastMode = false; + + function setMode(past) {{ + pastMode = past; + modeBuild.classList.toggle('active', !past); + modePast.classList.toggle('active', past); + pastPanel.hidden = !past; + // In past mode the chapters list and quota sidebar are meaningless. + document.querySelector('.zp-chapters-container').style.display = past ? 'none' : ''; + document.querySelectorAll('.zp-subject-pills-bar, .zp-search-row, #btn-complete-syllabus, .zp-complete-hint').forEach(el => {{ + el.style.display = past ? 'none' : ''; + }}); + document.getElementById('diff-row').closest('.zp-filter-sec').style.display = past ? 'none' : ''; + pastField.value = past ? '1' : ''; + if (past) {{ + // one flat 180-minute paper, all questions of the year + jeeBox.checked = false; + jeeBox.dispatchEvent(new Event('change')); + countInput.value = 75; + }} + sync(); + }} + if (modeBuild) modeBuild.addEventListener('click', () => setMode(false)); + if (modePast) modePast.addEventListener('click', () => setMode(true)); + + // Radio pills inside the past panel toggle their .active class + document.querySelectorAll('#zp-past-panel input[type=radio]').forEach(r => {{ + r.addEventListener('change', () => {{ + r.closest('.zp-pill-row').querySelectorAll('.zp-filter-pill').forEach(p => p.classList.remove('active')); + r.closest('.zp-filter-pill').classList.add('active'); + sync(); + }}); + }}); + // 0. Complete syllabus: check every chapter across all subjects const completeBtn = document.getElementById('btn-complete-syllabus'); if (completeBtn) {{ @@ -778,6 +840,18 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec, _form: &BuildForm // 11. Sync state, pill badges, and submit button function sync() {{ + if (pastMode) {{ + const yr = document.querySelector('input[name=past-year]:checked'); + const sh = document.querySelector('input[name=past-shift]:checked'); + yearField.value = yr ? yr.value : ''; + shiftField.value = sh ? sh.value : 'ALL'; + summaryText.innerHTML = yr + ? `JEE Main ${{yr.value}}${{sh && sh.value !== 'ALL' ? ' ' + sh.value : ''}}every question of that paper` + : 'pick a yearabove'; + submitBtn.disabled = !yr; + btnDesc.textContent = yr ? 'past paper · 180 minutes' : 'pick a year first'; + return; + }} const checkedChaps = [...document.querySelectorAll('.chap:checked')]; const checkedSubs = [...document.querySelectorAll('.subtopic:checked')]; chapField.value = checkedChaps.map(c => c.value).join(','); diff --git a/static/style.css b/static/style.css index d7d6a21..25bd818 100644 --- a/static/style.css +++ b/static/style.css @@ -2797,3 +2797,25 @@ img.brand-logo { position: static; } } + +/* Past-paper picker panel */ +.zp-past-panel { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--panel); + padding: 14px 16px; + margin-bottom: 16px; +} + +.zp-past-panel .zp-filter-heading { + display: block; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--text-muted); + margin-bottom: 8px; +} + +.zp-past-panel .zp-filter-pill input[type=radio] { + accent-color: #2563eb; +}