Previous-year-paper mode: year+shift picker builds the full actual paper (exam.rs past branch, build-page mode cards wired)

This commit is contained in:
spearzificial 2026-10-18 18:42:40 +05:30
parent 78f4ac2507
commit e4c51126a0
4 changed files with 161 additions and 1 deletions

View file

@ -62,6 +62,12 @@ pub struct BuildForm {
/// "jee" means 3 subjects × 60 min each; otherwise one flat timer. /// "jee" means 3 subjects × 60 min each; otherwise one flat timer.
#[serde(default)] #[serde(default)]
pub mode: String, 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 { impl BuildForm {
@ -87,9 +93,16 @@ impl BuildForm {
pub fn is_jee_mode(&self) -> bool { pub fn is_jee_mode(&self) -> bool {
self.mode == "jee" 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)> { 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(); let chapters = f.chapter_ids();
if chapters.is_empty() { if chapters.is_empty() {
anyhow::bail!("Pick at least one chapter"); 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?; tx.commit().await?;
Ok((score, max, used)) Ok((score, max, used))
} }
/// 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<String>) = 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<i64> = 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))
}

View file

@ -315,6 +315,8 @@ async fn build_page(State(ctx): State<CtxArc>, h: HeaderMap) -> axum::response::
duration: Some(30), duration: Some(30),
unseen_only: Some("on".into()), unseen_only: Some("on".into()),
mode: String::new(), mode: String::new(),
past: String::new(),
shift: String::new(),
}; };
hx(views::build(&ctx, &name, blocks, &form, available, None), StatusCode::OK).into_response() hx(views::build(&ctx, &name, blocks, &form, available, None), StatusCode::OK).into_response()
} }

View file

@ -496,6 +496,25 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm
</div> </div>
</div> </div>
<!-- Past-paper picker (hidden until Previous-year mode) -->
<div class="zp-past-panel" id="zp-past-panel" hidden>
<span class="zp-filter-heading">PICK A YEAR</span>
<div class="zp-pill-row" id="past-year-row">
<label class="zp-filter-pill"><input type="radio" name="past-year" value="2025"><span>2025</span></label>
<label class="zp-filter-pill"><input type="radio" name="past-year" value="2024"><span>2024</span></label>
<label class="zp-filter-pill"><input type="radio" name="past-year" value="2023"><span>2023</span></label>
<label class="zp-filter-pill"><input type="radio" name="past-year" value="2022"><span>2022</span></label>
<label class="zp-filter-pill"><input type="radio" name="past-year" value="2021"><span>2021</span></label>
<label class="zp-filter-pill"><input type="radio" name="past-year" value="2020"><span>2020</span></label>
</div>
<span class="zp-filter-heading" style="margin-top:14px">SHIFT</span>
<div class="zp-pill-row" id="past-shift-row">
<label class="zp-filter-pill active"><input type="radio" name="past-shift" value="ALL" checked><span>any</span></label>
<label class="zp-filter-pill"><input type="radio" name="past-shift" value="S1"><span>shift 1</span></label>
<label class="zp-filter-pill"><input type="radio" name="past-shift" value="S2"><span>shift 2</span></label>
</div>
</div>
<button type="button" class="zp-complete-btn" id="btn-complete-syllabus">complete syllabus</button> <button type="button" class="zp-complete-btn" id="btn-complete-syllabus">complete syllabus</button>
<p class="zp-complete-hint">Selects every chapter in all three subjects. You can still remove any you don't want.</p> <p class="zp-complete-hint">Selects every chapter in all three subjects. You can still remove any you don't want.</p>
@ -537,6 +556,8 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm
<input type="hidden" name="difficulties" id="difficulties" value="Easy,Medium,Hard"> <input type="hidden" name="difficulties" id="difficulties" value="Easy,Medium,Hard">
<input type="hidden" name="years" id="years" value="2020,2021,2022,2023,2024,2025"> <input type="hidden" name="years" id="years" value="2020,2021,2022,2023,2024,2025">
<input type="hidden" name="mode" id="mode" value="jee"> <input type="hidden" name="mode" id="mode" value="jee">
<input type="hidden" name="past" id="past" value="">
<input type="hidden" name="shift" id="shift" value="">
<!-- Chapter Rows Container --> <!-- Chapter Rows Container -->
<div class="zp-chapters-container"> <div class="zp-chapters-container">
@ -616,6 +637,47 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm
const btnDesc = document.getElementById('btn-desc'); const btnDesc = document.getElementById('btn-desc');
const searchInput = document.getElementById('zp-search-input'); 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 // 0. Complete syllabus: check every chapter across all subjects
const completeBtn = document.getElementById('btn-complete-syllabus'); const completeBtn = document.getElementById('btn-complete-syllabus');
if (completeBtn) {{ if (completeBtn) {{
@ -778,6 +840,18 @@ pub fn build(ctx: &Ctx, user: &str, blocks: Vec<SubjectBlock>, _form: &BuildForm
// 11. Sync state, pill badges, and submit button // 11. Sync state, pill badges, and submit button
function sync() {{ 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
? `<b>JEE Main ${{yr.value}}${{sh && sh.value !== 'ALL' ? ' ' + sh.value : ''}}</b><span class="dim">every question of that paper</span>`
: '<b>pick a year</b><span class="dim">above</span>';
submitBtn.disabled = !yr;
btnDesc.textContent = yr ? 'past paper · 180 minutes' : 'pick a year first';
return;
}}
const checkedChaps = [...document.querySelectorAll('.chap:checked')]; const checkedChaps = [...document.querySelectorAll('.chap:checked')];
const checkedSubs = [...document.querySelectorAll('.subtopic:checked')]; const checkedSubs = [...document.querySelectorAll('.subtopic:checked')];
chapField.value = checkedChaps.map(c => c.value).join(','); chapField.value = checkedChaps.map(c => c.value).join(',');

View file

@ -2797,3 +2797,25 @@ img.brand-logo {
position: static; 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;
}