// The exercise screen: one skill, one question at a time, as a dialog over the path (mathly1's sheet). // Tap a choice and the server grades it; a wrong answer names the mistake and never marks the right choice. import { api, ApiError } from "./api.js"; import { figures } from "./figures.js"; import { clear as clearComet, cross as crossComet } from "./comets.js"; import { esc, fmt } from "./math.js"; import * as aiSheet from "./ai_sheet.js"; import { pathHref, shuffled } from "./ui.js"; import { sendWorkQuietly } from "./boardwork.js"; const COUNTDOWN = 3; // seconds before the next question after a right answer let layer = null, skill = null, timer = null, helped = false, boardReady = null, aiOn = false; const script = src => new Promise((ok, no) => { const s = document.createElement("script"); s.src = src; s.onload = ok; s.onerror = no; document.head.append(s); }); /** The board and its styles load the first time a sheet opens, not with the app. If they cannot load, the sheet works without it. */ export function loadBoard() { return boardReady ||= new Promise(done => { const link = Object.assign(document.createElement("link"), { rel: "stylesheet", href: "/static/board.css" }); link.onload = () => script("/static/board-math.js").then(() => script("/static/math-board.js")).then(done, done); link.onerror = () => done(); document.head.append(link); }); } const q = selector => layer?.querySelector(selector); const stop = () => { clearTimeout(timer); timer = null; }; export const isOpen = () => !!layer; /** Open (or switch to) a skill's sheet. */ export async function open(id) { stop(); const [data, , ai] = await Promise.all([api(`/api/skills/${encodeURIComponent(id)}`), loadBoard(), aiSheet.status()]); skill = data; aiOn = !!ai.on; // photo help only for a class with the AI on (AI-10) if (!layer) { layer = document.createElement("div"); layer.className = "mx-layer"; document.body.append(layer); layer.addEventListener("click", onClick); } layer.innerHTML = `
`; render(); q(".mx-x")?.focus({ preventScroll: true }); } /** Close the sheet: stop timers, bring the board back from full screen, and remove everything. */ export function close() { if (!layer) return; aiSheet.close(); stop(); clearComet(); window.MathlyBoard?.closeFull?.(); layer.remove(); layer = skill = null; } const heading = () => `
${fmt(skill.lesson.name)}

${fmt(skill.name)}${skill.state === "mastered" ? ' ✓ נשלטה' : ""}

${window.MathlyBoard?.button() || ""} ${aiOn ? `` : ""}
`; /** Where the student is in the round: "question n of N" and a dot per question (filled for right, marked ✗ for wrong, empty when not answered). */ const dots = round => Array.from({ length: round.total }, (_, i) => { const result = round.results[i]; return `${result === false ? "✗" : ""}`; }).join(""); const countText = round => round.done ? "הסבב הסתיים" : `שאלה ${Math.min(round.answered + 1, round.total)} מתוך ${round.total}`; const dotsLabel = round => `נענו ${round.answered} מתוך ${round.total}, ${round.results.filter(Boolean).length} נכונות`; const progress = () => { const round = skill.round; return round.total ? `
${countText(round)} ${dots(round)}
` : ""; }; /** The round's result: how it went and the mistakes, never the right answers. */ function roundResult() { const { result } = skill.round; return `

סיכום הסבב

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

${skill.state === "mastered" ? `

🎉 המיומנות נשלטה!

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

מה כדאי לחזק

    ${result.missed.map(m => `
  1. ${fmt(m.question)}

    ${m.mistake ? `

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

    ` : ""} ${m.back ? `

    כדאי לחזור אל: ${fmt(m.back.name)}

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

כל התשובות נכונות. כל הכבוד!

`}

`; } const helpBox = () => { const ex = skill.help.solved_example; return ``; }; const related = () => { const chips = (title, items) => items.length ? `` : ""; return chips("דורשת:", skill.requires) + chips("פותחת:", skill.opens); }; function questionHtml() { const question = skill.question; if (!question && skill.round.done) return roundResult(); if (!question) return `

אין עדיין שאלות תרגול למיומנות הזו.

`; return `

${fmt(question.question)}

${figures(question)}
${shuffled(question.choices).map(c => ``).join("")}
`; } /** Mount the board for the question on screen: each question keeps its own sketch. */ function mountBoard() { const key = skill.question ? `${skill.id}:${skill.question.key}` : `${skill.id}:none`; window.MathlyBoard?.mount(layer, key, { question: () => q("#ex-q") }); } function render(notice = "") { q(".mx-sheet").innerHTML = `${heading()}

${fmt(skill.description)}

${notice ? `

${esc(notice)}

` : ""} ${helpBox()}${progress()}${questionHtml()}${related()}`; helped = false; mountBoard(); } function showNextQuestion(question) { stop(); skill.question = question; q("#ex-q")?.remove(); q("#no-practice")?.remove(); const holder = document.createElement("div"); holder.innerHTML = questionHtml(); (q("#ex-dots")?.parentElement || q(".ex-explain")).after(holder.firstElementChild); q("#ex-helpbox").hidden = true; q("[data-help]")?.setAttribute("aria-expanded", "false"); helped = false; mountBoard(); } function updateProgress() { const round = skill.round; q("#ex-count").textContent = countText(round); const row = q("#ex-dots"); row.setAttribute("aria-label", dotsLabel(round)); row.innerHTML = dots(round); if (skill.state === "mastered" && !q(".ex-done")) q("#ex-title").insertAdjacentHTML("beforeend", ' ✓ נשלטה'); } /** The round is over: show its result in place of the question. */ function showResult() { stop(); skill.question = null; render(); } async function newRound() { const button = q("#ex-newround"); button.disabled = true; try { await api(`/api/skills/${encodeURIComponent(skill.id)}/round`, { method: "POST" }); skill = await api(`/api/skills/${encodeURIComponent(skill.id)}`); render(); } catch (error) { button.disabled = false; button.insertAdjacentHTML("afterend", ``); } } async function reloadCurrent() { skill = await api(`/api/skills/${encodeURIComponent(skill.id)}`); stop(); render("השאלה התחלפה בחלון אחר. זו השאלה שעל המסך עכשיו."); } 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 question = skill.question; const buttons = [...layer.querySelectorAll(".ex-choice")]; buttons.forEach(b => { b.disabled = true; b.classList.toggle("dim", b !== button); }); button.classList.add("pending"); // the thinking indicator: the server is checking button.setAttribute("aria-busy", "true"); q("#ex-fb").innerHTML = `

בודקים…

`; // a live region: a screen reader hears it too let result; try { result = await api(`/api/skills/${encodeURIComponent(skill.id)}/answer`, { method: "POST", body: { question_key: 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) return reloadCurrent(); buttons.forEach(b => { b.disabled = false; }); q("#ex-fb").innerHTML = ``; return; } buttons.forEach(b => b.classList.remove("pending", "dim")); button.removeAttribute("aria-busy"); sendWorkQuietly(`${skill.id}:${question.key}`, "practice", question.key); // the board of this answer, for the teacher Object.assign(skill, { round: result.round, state: result.state, correct_in_row: result.correct_in_row, today: result.today }); updateProgress(); const feedback = q("#ex-fb"); const next = () => (result.next_question ? showNextQuestion(result.next_question) : showResult()); if (result.correct) { button.classList.add("right"); button.insertAdjacentHTML("beforeend", ''); navigator.vibrate?.(25); feedback.innerHTML = `
נכון ✓ ${result.correct_in_row} ${result.correct_in_row === 1 ? "תשובה נכונה" : "תשובות נכונות"} ברצף (מתוך 3) ${result.mastered_now ? `

🎉 המיומנות נשלטה!

` : ""} ${result.next_question ? `
` : `

סיימתם את הסבב.

`}
`; q("#ex-results")?.addEventListener("click", next); if (result.comet) crossComet({ feedback }); if (result.next_question) { q("#ex-skip").addEventListener("click", next); q("#ex-pause").addEventListener("click", () => { stop(); q("#ex-countdown").textContent = ""; q("#ex-pause").remove(); }); countdown(next); } } else { button.classList.add("wrong"); // only the chosen one is marked, with a symbol and a word (not colour alone); the right choice is never shown button.insertAdjacentHTML("beforeend", ' לא נכון'); const m = result.mistake; feedback.innerHTML = `
${esc(m?.name || "תשובה לא נכונה")}${m?.tip ? `

${fmt(m.tip)}

` : ""} ${m?.back && m.back_name ? `

כדאי לחזור אל: ${fmt(m.back_name)}

` : ""}
`; // a wrong question does not come back in the round q("#ex-other").addEventListener("click", next); } } async function openHelp() { const box = q("#ex-helpbox"), button = q("[data-help]"); const opening = box.hidden; box.hidden = !opening; button.setAttribute("aria-expanded", String(opening)); if (opening && skill.question && !helped && !q("#ex-fb").children.length) { // help before answering: this answer will not count helped = true; try { await api(`/api/skills/${encodeURIComponent(skill.id)}/help`, { method: "POST" }); } catch { helped = false; } } } function onClick(event) { const target = event.target; if (target.closest("[data-close]")) { location.hash = pathHref(); return; } if (target.closest("[data-board-toggle]")) { window.MathlyBoard?.toggle(); return; } if (target.closest("[data-ai]")) { aiSheet.open({ skillId: skill.id }); return; } if (target.closest("[data-help]")) { openHelp(); return; } if (target.closest("#ex-newround")) { newRound(); return; } const choice = target.closest(".ex-choice"); if (choice && !choice.disabled) answer(choice); } window.addEventListener("mathly:leave", close); // another role's screen opened, or the person signed out document.addEventListener("keydown", event => { if (event.key === "Escape" && layer && !document.querySelector(".mx-board.full")) location.hash = pathHref(); });