File size: 12,693 Bytes
72ba2ce 6958fef 72ba2ce 6958fef aeda545 72ba2ce 6958fef 72ba2ce 6958fef 72ba2ce 2067656 72ba2ce 6958fef 2067656 6958fef 72ba2ce 6958fef 72ba2ce aeda545 72ba2ce 6958fef 72ba2ce 6958fef 72ba2ce 6958fef 72ba2ce | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | // 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 = `<div class="mx-scrim" data-close></div><div class="mx-sheet" role="dialog" aria-modal="true" aria-labelledby="rd-title"></div>`;
render();
q(".mx-x")?.focus({ preventScroll: true });
}
export function close() {
if (!layer) return;
stop();
window.MathlyBoard?.closeFull?.();
layer.remove();
layer = round = null;
}
const heading = () => `<div class="mx-shtop"><div><span class="ex-chip">${isRetry() ? "חזרה על הטעויות" : "סבב שיעור"}</span><h3 id="rd-title">${fmt(round.lesson.name)}</h3></div>
<div class="mx-shacts">${window.MathlyBoard?.button() || ""}<button type="button" class="mx-x" data-close aria-label="סגירה">✕</button></div></div>`;
const dots = () => Array.from({ length: round.total }, (_, i) => {
const r = round.results[i];
return `<i class="${r === true ? "ok" : r === false ? "bad" : ""}">${r === false ? "✗" : ""}</i>`;
}).join("");
const countText = () => round.state === "done" ? "הסבב הסתיים" : `שאלה ${Math.min(round.results.length + 1, round.total)} מתוך ${round.total}`;
const progress = () => `<div class="ex-progress"><span id="rd-count">${countText()}</span>
<span class="ex-dots" id="rd-dots" role="img" aria-label="נענו ${round.results.length} מתוך ${round.total}, ${round.results.filter(Boolean).length} נכונות">${dots()}</span></div>`;
function questionHtml() {
const question = round.question;
const ctx = question.context;
const from = ctx ? `<div class="rd-context" id="rd-context"><span class="ex-chip">📝 ${ctx.n ? `שאלה ${ctx.n}${ctx.of ? ` מתוך ${ctx.of}` : ""} ב${fmt(ctx.title)}` : fmt(ctx.title)}</span>
<p class="muted">${ctx.picked ? `במבחן בחרתם: <span dir="auto">${fmt(ctx.picked)}</span>` : "במבחן לא עניתם על השאלה הזו."} נסו שוב.</p></div>` : "";
return `<div class="mx-q" id="ex-q" data-key="${esc(question.key)}">${from}<p class="muted rd-skill">${fmt(question.skill_name)}</p>
<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>`;
}
/** 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 `<section class="rd-result" id="rd-result" data-kind="retry">
<p class="rd-bigstars" aria-hidden="true">${all ? "🎉" : "💪"}</p>
<h4 class="rd-title">${all ? "כל הטעויות תוקנו!" : "סיימתם לחזור על הטעויות"}</h4>
<p class="ex-score" id="rd-score"><b>${r.right}</b> מתוך ${r.total} נכונות הפעם</p>
${r.mastered.length ? `<p class="rd-mastered" id="rd-mastered">🎉 נשלטו: ${r.mastered.map(s => fmt(s.name)).join(", ")}</p>` : ""}
${r.missed.length ? `<h4>עדיין כדאי לחזק</h4><ol class="ex-missed" id="rd-missed">${r.missed.map(m => `<li><p class="muted">${m.n ? `שאלה ${m.n} · ` : ""}${fmt(m.skill_name)}</p><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>` : ""}</li>`).join("")}</ol>` : ""}
<p class="muted">הציון במבחן לא משתנה. התרגול נספר לשליטה במיומנויות.</p>
<p class="ex-actions">${all ? "" : `<button type="button" class="primary" id="rd-again">שוב על הטעויות</button> `}<button type="button" data-close>${round.source.test_id ? "חזרה למבחן" : "חזרה למסלול"}</button></p></section>`;
}
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 `<section class="rd-result" id="rd-result">
<p class="rd-bigstars" aria-label="${r.stars} מתוך 3 כוכבים">${[0, 1, 2].map(i => `<span class="${i < r.stars ? "on" : ""}" style="--i:${i}">${i < r.stars ? "★" : "☆"}</span>`).join("")}</p>
<h4 class="rd-title">${title}</h4>
<p class="ex-score" id="rd-score"><b>${r.right}</b> מתוך ${r.total}</p>
${r.mastered.length ? `<p class="rd-mastered" id="rd-mastered">🎉 נשלטו: ${names(r.mastered)}</p>` : ""}
${r.almost.length ? `<p class="rd-almost" id="rd-almost">🔜 עוד סבב טוב ביום אחר ונשלטות: ${names(r.almost)}</p>` : ""}
${r.stars < 3 ? `<p class="muted rd-next">${r.stars < 2 ? "8 מתוך 10 נותנים כוכב שני." : "הכוכב השלישי: כל המיומנויות נשלטו. חוזרים מחר לעוד סבב."}</p>` : ""}
${r.missed.length ? `<h4>מה כדאי לחזק</h4><ol class="ex-missed" id="rd-missed">${r.missed.map(m => `<li><p class="muted">${fmt(m.skill_name)}</p><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>` : ""}</li>`).join("")}</ol>` : ""}
<p class="ex-actions"><button type="button" class="primary" id="rd-again">סבב נוסף</button> <button type="button" data-close>חזרה למפה</button></p></section>`;
}
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 ? `<p class="ex-note" role="status">${esc(notice)}</p>` : ""}${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 = `<p class="ex-thinking">בודקים…</p>`;
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 = `<p class="error" role="alert">${esc(error.message || "משהו השתבש")}</p>`;
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 ? `<p class="rd-mastered">🎉 ${fmt(result.mastered_now.name)}: נשלטה!</p>` : "";
if (result.correct) {
streak += 1;
button.classList.add("right");
button.insertAdjacentHTML("beforeend", '<span class="burst" aria-hidden="true">✦ ✦ ✦</span>');
navigator.vibrate?.(25);
feedback.innerHTML = `<div class="ex-ok"><b>${CHEERS[(streak - 1) % CHEERS.length]} ✓</b>${streak >= 3 ? ` <span>🔥 ${streak} ברצף</span>` : ""}${mastered}
<div class="ex-actions">${last ? `<button type="button" class="primary" id="rd-next">לתוצאות ✨</button>`
: `<span id="ex-countdown"></span><button type="button" id="rd-next">לשאלה הבאה</button>`}</div></div>`;
if (!last) countdown(next);
} else {
streak = 0;
button.classList.add("wrong");
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>` : ""}
<div class="ex-actions"><button type="button" id="rd-next">${last ? "לתוצאות" : "לשאלה הבאה"}</button></div></div>`;
}
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", `<p class="error" role="alert">${esc(error.message || "משהו השתבש")}</p>`);
}
}
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();
});
|