// A lesson round (סבב שיעור), opened from the home's map: a short round of the lesson's skills, one question at a time, with instant feedback // like the exercise screen. A wrong answer names the mistake and never marks the right choice. The last answer shows the lesson's stars. // The same sheet plays a mistakes round (חזרה על הטעויות): the questions a handed-in quiz, exercise test or simulation got wrong, with no stars. import { api, ApiError } from "./api.js"; import { figures } from "./figures.js"; import { esc, fmt } from "./math.js"; import { loadBoard } from "./exercise.js"; import { homeHref, pathHref, shuffled } from "./ui.js"; import { sendWorkQuietly } from "./boardwork.js"; const COUNTDOWN = 2; // seconds before the next question after a right answer let layer = null, round = null, timer = null, streak = 0; const q = selector => layer?.querySelector(selector); const stop = () => { clearTimeout(timer); timer = null; }; const starsText = n => "★".repeat(n) + "☆".repeat(3 - n); const CHEERS = ["נכון!", "יופי!", "מצוין!", "בדיוק!", "אלופים!"]; export const isOpen = () => !!layer; /** Start the lesson's round (or resume the open one). Returns the round, so the router knows its subject. */ export const start = lesson => api(`/api/lessons/${encodeURIComponent(lesson)}/round/start`, { method: "POST" }); /** Start (or resume) the mistakes round of a handed-in attempt. */ export const startRetry = attemptId => api(`/api/attempts/${encodeURIComponent(attemptId)}/retry/start`, { method: "POST" }); const isRetry = () => !!round?.source; /** Where closing leads: the home's map, or for a mistakes round its test's card (a quiz: the path). */ const leaveTo = () => round?.source ? (round.source.test_id ? `#/test/${round.source.test_id}` : pathHref()) : homeHref(); /** Show a round from `start` as a sheet over the home. */ export async function show(data) { stop(); await loadBoard(); round = data; streak = 0; if (!layer) { layer = document.createElement("div"); layer.className = "mx-layer round-layer"; document.body.append(layer); layer.addEventListener("click", onClick); } layer.innerHTML = `
`; render(); q(".mx-x")?.focus({ preventScroll: true }); } export function close() { if (!layer) return; stop(); window.MathlyBoard?.closeFull?.(); layer.remove(); layer = round = null; } const heading = () => `
${isRetry() ? "חזרה על הטעויות" : "סבב שיעור"}

${fmt(round.lesson.name)}

${window.MathlyBoard?.button() || ""}
`; const dots = () => Array.from({ length: round.total }, (_, i) => { const r = round.results[i]; return `${r === false ? "✗" : ""}`; }).join(""); const countText = () => round.state === "done" ? "הסבב הסתיים" : `שאלה ${Math.min(round.results.length + 1, round.total)} מתוך ${round.total}`; const progress = () => `
${countText()} ${dots()}
`; function questionHtml() { const question = round.question; const ctx = question.context; const from = ctx ? `
📝 ${ctx.n ? `שאלה ${ctx.n}${ctx.of ? ` מתוך ${ctx.of}` : ""} ב${fmt(ctx.title)}` : fmt(ctx.title)}

${ctx.picked ? `במבחן בחרתם: ${fmt(ctx.picked)}` : "במבחן לא עניתם על השאלה הזו."} נסו שוב.

` : ""; return `
${from}

${fmt(question.skill_name)}

${fmt(question.question)}

${figures(question)}
${shuffled(question.choices).map(c => ``).join("")}
`; } /** The round's result: the stars big, the score, what was mastered, what is one good day away, and the mistakes (never the right answers). */ function retryResultHtml() { const r = round.result, all = r.right === r.total; return `

${all ? "כל הטעויות תוקנו!" : "סיימתם לחזור על הטעויות"}

${r.right} מתוך ${r.total} נכונות הפעם

${r.mastered.length ? `

🎉 נשלטו: ${r.mastered.map(s => fmt(s.name)).join(", ")}

` : ""} ${r.missed.length ? `

עדיין כדאי לחזק

    ${r.missed.map(m => `
  1. ${m.n ? `שאלה ${m.n} · ` : ""}${fmt(m.skill_name)}

    ${fmt(m.question)}

    ${m.mistake ? `

    ${esc(m.mistake.name)}${m.mistake.tip ? `
    ${fmt(m.mistake.tip)}` : ""}

    ` : ""}
  2. `).join("")}
` : ""}

הציון במבחן לא משתנה. התרגול נספר לשליטה במיומנויות.

${all ? "" : ` `}

`; } function resultHtml() { if (isRetry()) return retryResultHtml(); const r = round.result; const names = list => list.map(s => fmt(s.name)).join(", "); const title = r.stars === 3 ? "👑 השיעור מושלם!" : r.right === r.total ? "מושלם! כל התשובות נכונות" : r.right * 10 >= r.total * 8 ? "כל הכבוד!" : "סיימתם סבב!"; return `

${[0, 1, 2].map(i => `${i < r.stars ? "★" : "☆"}`).join("")}

${title}

${r.right} מתוך ${r.total}

${r.mastered.length ? `

🎉 נשלטו: ${names(r.mastered)}

` : ""} ${r.almost.length ? `

🔜 עוד סבב טוב ביום אחר ונשלטות: ${names(r.almost)}

` : ""} ${r.stars < 3 ? `

${r.stars < 2 ? "8 מתוך 10 נותנים כוכב שני." : "הכוכב השלישי: כל המיומנויות נשלטו. חוזרים מחר לעוד סבב."}

` : ""} ${r.missed.length ? `

מה כדאי לחזק

    ${r.missed.map(m => `
  1. ${fmt(m.skill_name)}

    ${fmt(m.question)}

    ${m.mistake ? `

    ${esc(m.mistake.name)}${m.mistake.tip ? `
    ${fmt(m.mistake.tip)}` : ""}

    ` : ""}
  2. `).join("")}
` : ""}

`; } function mountBoard() { const key = round.question ? `round:${round.id}:${round.question.key}` : `round:${round.id}:done`; window.MathlyBoard?.mount(layer, key, { question: () => q("#ex-q") }); } function render(notice = "") { q(".mx-sheet").innerHTML = `${heading()}${notice ? `

${esc(notice)}

` : ""}${progress()} ${round.state === "done" ? resultHtml() : questionHtml()}`; mountBoard(); } function updateProgress() { q("#rd-count").textContent = countText(); q("#rd-dots").innerHTML = dots(); } function countdown(next) { let left = COUNTDOWN; const label = q("#ex-countdown"); const tick = () => { if (!layer || !label?.isConnected) return; if (left === 0) return next(); label.textContent = `הבאה בעוד ${left}…`; left -= 1; timer = setTimeout(tick, 1000); }; tick(); } async function answer(button) { const buttons = [...layer.querySelectorAll(".ex-choice")]; buttons.forEach(b => { b.disabled = true; b.classList.toggle("dim", b !== button); }); button.classList.add("pending"); button.setAttribute("aria-busy", "true"); q("#ex-fb").innerHTML = `

בודקים…

`; let result; try { result = await api(`/api/rounds/${encodeURIComponent(round.id)}/answer`, { method: "POST", body: { key: round.question.key, choice: Number(button.dataset.i) } }); } catch (error) { buttons.forEach(b => b.classList.remove("pending", "dim")); button.removeAttribute("aria-busy"); if (error instanceof ApiError && error.status === 409) { // answered in another tab: show where the round is now round = await api(`/api/rounds/${encodeURIComponent(round.id)}`); return render("הסבב התקדם בחלון אחר. זו השאלה שעל המסך עכשיו."); } buttons.forEach(b => { b.disabled = false; }); q("#ex-fb").innerHTML = ``; return; } buttons.forEach(b => b.classList.remove("pending", "dim")); button.removeAttribute("aria-busy"); sendWorkQuietly(`round:${round.id}:${round.question.key}`, round.id, round.question.key); // the board of this answer, for the teacher const following = result.round; round = { ...round, results: following.results, state: following.state }; updateProgress(); const next = () => { stop(); round = following; render(); }; const last = following.state === "done"; const feedback = q("#ex-fb"); const mastered = result.mastered_now ? `

🎉 ${fmt(result.mastered_now.name)}: נשלטה!

` : ""; if (result.correct) { streak += 1; button.classList.add("right"); button.insertAdjacentHTML("beforeend", ''); navigator.vibrate?.(25); feedback.innerHTML = `
${CHEERS[(streak - 1) % CHEERS.length]} ✓${streak >= 3 ? ` 🔥 ${streak} ברצף` : ""}${mastered}
${last ? `` : ``}
`; if (!last) countdown(next); } else { streak = 0; button.classList.add("wrong"); button.insertAdjacentHTML("beforeend", ' לא נכון'); const m = result.mistake; feedback.innerHTML = `
${esc(m?.name || "תשובה לא נכונה")}${m?.tip ? `

${fmt(m.tip)}

` : ""}
`; } q("#rd-next").addEventListener("click", next); } async function again() { const button = q("#rd-again"); button.disabled = true; try { round = await (isRetry() ? startRetry(round.source.id) : start(round.lesson.id)); streak = 0; render(); } catch (error) { button.disabled = false; button.insertAdjacentHTML("afterend", ``); } } function onClick(event) { const target = event.target; if (target.closest("[data-close]")) { location.hash = leaveTo(); return; } if (target.closest("[data-board-toggle]")) { window.MathlyBoard?.toggle(); return; } if (target.closest("#rd-again")) { again(); return; } const choice = target.closest(".ex-choice"); if (choice && !choice.disabled) answer(choice); } window.addEventListener("mathly:leave", close); document.addEventListener("keydown", event => { if (event.key === "Escape" && layer && !document.querySelector(".mx-board.full")) location.hash = leaveTo(); });