// 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 = () => `${result.right} מתוך ${result.total}
${skill.state === "mastered" ? `🎉 המיומנות נשלטה!
` : ""} ${result.missed.length ? `${fmt(m.question)}
${m.mistake ? `${esc(m.mistake.name)}${m.mistake.tip ? `
${fmt(m.mistake.tip)}` : ""}
כל התשובות נכונות. כל הכבוד!
`}
${fmt(skill.help.explanation)}
${ex ? `${fmt(ex.question)}
תשובה: ${fmt(ex.answer)}
` : ""} ${skill.question ? `פתחתם עזרה: התשובה לשאלה הזו לא תיספר לשליטה.
` : ""} ${aiOn ? `` : ""}אין עדיין שאלות תרגול למיומנות הזו.
`; return `${fmt(question.question)}
${figures(question)}${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", `${esc(error.message || "משהו השתבש")}
`); } } 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 = `${esc(error.message || "משהו השתבש")}
`; 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.next_question ? `סיימתם את הסבב.
`}${fmt(m.tip)}
` : ""} ${m?.back && m.back_name ? `` : ""}