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();
});