mathly / mathly2 /static /exercise.js
wzender's picture
feat(board-work): implement board work feature for student answers
aeda545
Raw History Blame Contribute Delete
14.7 kB
// 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 = `<div class="mx-scrim" data-close></div><div class="mx-sheet" role="dialog" aria-modal="true" aria-labelledby="ex-title"></div>`;
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 = () => `<div class="mx-shtop"><div><span class="ex-chip">${fmt(skill.lesson.name)}</span>
<h3 id="ex-title">${fmt(skill.name)}${skill.state === "mastered" ? ' <span class="ex-done" title="ื ืฉืœื˜ื”">โœ“ ื ืฉืœื˜ื”</span>' : ""}</h3></div>
<div class="mx-shacts">${window.MathlyBoard?.button() || ""}
${aiOn ? `<button type="button" class="ex-help ex-ai" data-ai aria-label="ืขื–ืจื” ืžืชืžื•ื ื”: ืฆืœืžื• ืชืจื’ื™ืœ ื•ืฉืœื—ื• ืœืžื•ืจื”" title="ืขื–ืจื” ืžืชืžื•ื ื”">๐Ÿ“ท</button>` : ""}
<button type="button" class="ex-help" data-help aria-label="ืขื–ืจื”: ื”ื”ืกื‘ืจ ื•ื”ื“ื•ื’ืžื” ื”ืคืชื•ืจื”" title="ืขื–ืจื”" aria-expanded="false">?</button>
<button type="button" class="mx-x" data-close aria-label="ืกื’ื™ืจื”">โœ•</button></div></div>`;
/** 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 `<i class="${result === true ? "ok" : result === false ? "bad" : ""}">${result === false ? "โœ—" : ""}</i>`;
}).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 ? `<div class="ex-progress"><span id="ex-count">${countText(round)}</span>
<span class="ex-dots" id="ex-dots" role="img" aria-label="${dotsLabel(round)}">${dots(round)}</span></div>` : "";
};
/** The round's result: how it went and the mistakes, never the right answers. */
function roundResult() {
const { result } = skill.round;
return `<section class="ex-round" id="ex-round"><h4>ืกื™ื›ื•ื ื”ืกื‘ื‘</h4>
<p class="ex-score" id="ex-score"><b>${result.right}</b> ืžืชื•ืš ${result.total}</p>
${skill.state === "mastered" ? `<p id="ex-mastered-now">๐ŸŽ‰ ื”ืžื™ื•ืžื ื•ืช ื ืฉืœื˜ื”!</p>` : ""}
${result.missed.length ? `<h4>ืžื” ื›ื“ืื™ ืœื—ื–ืง</h4><ol class="ex-missed" id="ex-missed">${result.missed.map(m => `<li><p class="ex-text" dir="auto">${fmt(m.question)}</p>
${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}
${m.back ? `<p><a href="#/skill/${esc(m.back.id)}">ื›ื“ืื™ ืœื—ื–ื•ืจ ืืœ: ${fmt(m.back.name)}</a></p>` : ""}</li>`).join("")}</ol>`
: `<p id="ex-perfect">ื›ืœ ื”ืชืฉื•ื‘ื•ืช ื ื›ื•ื ื•ืช. ื›ืœ ื”ื›ื‘ื•ื“!</p>`}
<p class="ex-actions"><button type="button" class="primary" id="ex-newround">ืกื‘ื‘ ื—ื“ืฉ</button> <button type="button" data-close>ื—ื–ืจื” ืœืžืกืœื•ืœ</button></p></section>`;
}
const helpBox = () => {
const ex = skill.help.solved_example;
return `<div class="ex-helpbox" id="ex-helpbox" hidden><h4>ื”ืกื‘ืจ</h4><p>${fmt(skill.help.explanation)}</p>
${ex ? `<h4>ื“ื•ื’ืžื” ืคืชื•ืจื”</h4><p class="ex-eq">${fmt(ex.question)}</p><ol class="ex-steps">${ex.steps.map(s =>
`<li>${fmt(s.do)}<br><span class="muted">${fmt(s.why)}</span></li>`).join("")}</ol><p><b>ืชืฉื•ื‘ื”:</b> ${fmt(ex.answer)}</p>` : ""}
${skill.question ? `<p class="muted" id="ex-helpnote">ืคืชื—ืชื ืขื–ืจื”: ื”ืชืฉื•ื‘ื” ืœืฉืืœื” ื”ื–ื• ืœื ืชื™ืกืคืจ ืœืฉืœื™ื˜ื”.</p>` : ""}
${aiOn ? `<p><button type="button" data-ai id="ex-stuck">ืขื“ื™ื™ืŸ ืชืงื•ืขื™ื? ืฆืœืžื•</button></p>` : ""}</div>`;
};
const related = () => {
const chips = (title, items) => items.length ? `<p class="ex-related"><span class="muted">${title}</span> ${items.map(s =>
`<a class="ex-chip link" href="#/skill/${esc(s.id)}">${fmt(s.name)}</a>`).join(" ")}</p>` : "";
return chips("ื“ื•ืจืฉืช:", skill.requires) + chips("ืคื•ืชื—ืช:", skill.opens);
};
function questionHtml() {
const question = skill.question;
if (!question && skill.round.done) return roundResult();
if (!question) return `<p class="muted" id="no-practice">ืื™ืŸ ืขื“ื™ื™ืŸ ืฉืืœื•ืช ืชืจื’ื•ืœ ืœืžื™ื•ืžื ื•ืช ื”ื–ื•.</p>`;
return `<div class="mx-q" id="ex-q" data-key="${esc(question.key)}"><p class="ex-text" dir="auto">${fmt(question.question)}</p>${figures(question)}
<div class="ex-choices" id="ex-choices">${shuffled(question.choices).map(c =>
`<button type="button" class="ex-choice" data-i="${c.index}" dir="auto">${fmt(c.text)}</button>`).join("")}</div>
<div class="ex-fb" id="ex-fb" role="status" aria-live="polite"></div></div>`;
}
/** 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()}<p class="ex-explain">${fmt(skill.description)}</p>${notice ? `<p class="ex-note" role="status">${esc(notice)}</p>` : ""}
${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", ' <span class="ex-done" title="ื ืฉืœื˜ื”">โœ“ ื ืฉืœื˜ื”</span>');
}
/** 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", `<p class="error" role="alert">${esc(error.message || "ืžืฉื”ื• ื”ืฉืชื‘ืฉ")}</p>`);
}
}
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 = `<p class="ex-thinking" id="ex-thinking">ื‘ื•ื“ืงื™ืโ€ฆ</p>`; // 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 = `<p class="error" role="alert">${esc(error.message || "ืžืฉื”ื• ื”ืฉืชื‘ืฉ")}</p>`;
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", '<span class="burst" aria-hidden="true">โœฆ โœฆ โœฆ</span>');
navigator.vibrate?.(25);
feedback.innerHTML = `<div class="ex-ok"><b>ื ื›ื•ืŸ โœ“</b> <span>${result.correct_in_row} ${result.correct_in_row === 1 ? "ืชืฉื•ื‘ื” ื ื›ื•ื ื”" : "ืชืฉื•ื‘ื•ืช ื ื›ื•ื ื•ืช"} ื‘ืจืฆืฃ (ืžืชื•ืš 3)</span>
${result.mastered_now ? `<p id="ex-mastered">๐ŸŽ‰ ื”ืžื™ื•ืžื ื•ืช ื ืฉืœื˜ื”!</p>` : ""}
${result.next_question ? `<div class="ex-actions"><span id="ex-countdown"></span><button type="button" id="ex-skip">ืœืฉืืœื” ื”ื‘ืื”</button><button type="button" id="ex-pause">ืขืฆื™ืจื”</button></div>`
: `<p>ืกื™ื™ืžืชื ืืช ื”ืกื‘ื‘.</p><div class="ex-actions"><button type="button" class="primary" id="ex-results">ืœืชื•ืฆืื•ืช ื”ืกื‘ื‘</button></div>`}</div>`;
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", '<span class="verdict"><span aria-hidden="true">โœ—</span> ืœื ื ื›ื•ืŸ</span>');
const m = result.mistake;
feedback.innerHTML = `<div class="ex-bad"><b>${esc(m?.name || "ืชืฉื•ื‘ื” ืœื ื ื›ื•ื ื”")}</b>${m?.tip ? `<p>${fmt(m.tip)}</p>` : ""}
${m?.back && m.back_name ? `<p><a href="#/skill/${esc(m.back)}">ื›ื“ืื™ ืœื—ื–ื•ืจ ืืœ: ${fmt(m.back_name)}</a></p>` : ""}
<div class="ex-actions"><button type="button" id="ex-other">${result.next_question ? "ืœืฉืืœื” ื”ื‘ืื”" : "ืœืชื•ืฆืื•ืช ื”ืกื‘ื‘"}</button></div></div>`; // 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();
});