3v324v23 commited on
Commit
a6c6bd0
ยท
1 Parent(s): 816d6ad
app/answer_routes.py CHANGED
@@ -63,7 +63,7 @@ def install_answer_routes(app, signed_in):
63
  @app.post("/api/math/answer-attempts")
64
  def resume_attempt(body: dict, user=Depends(student)):
65
  question = next((s for s in algebra9()["skills"] if s["id"] == body.get("question_id")), None)
66
- if not question or question["sample"].get("type") != "open-ended":
67
  raise ScanError(404, "not_found")
68
  sample = question["sample"]
69
  version = question_version(sample)
 
63
  @app.post("/api/math/answer-attempts")
64
  def resume_attempt(body: dict, user=Depends(student)):
65
  question = next((s for s in algebra9()["skills"] if s["id"] == body.get("question_id")), None)
66
+ if not question or question.get("sample", {}).get("type") != "open-ended":
67
  raise ScanError(404, "not_found")
68
  sample = question["sample"]
69
  version = question_version(sample)
app/main.py CHANGED
@@ -33,8 +33,11 @@ load_dotenv(ROOT / ".env")
33
 
34
 
35
  def units_of(row: dict) -> list[str]:
36
- known = [unit["id"] for unit in algebra9()["units"]]
 
 
37
  selected = set(row.get("lessons") or known)
 
38
  return [unit for unit in known if unit in selected]
39
 
40
 
 
33
 
34
 
35
  def units_of(row: dict) -> list[str]:
36
+ """A class's units. A unit split off an older one (its `from`) comes with it, so saved classes keep their content."""
37
+ units = algebra9()["units"]
38
+ known = [unit["id"] for unit in units]
39
  selected = set(row.get("lessons") or known)
40
+ selected |= {unit["id"] for unit in units if selected & set(unit.get("from", []))}
41
  return [unit for unit in known if unit in selected]
42
 
43
 
app/math_algebra9.json CHANGED
The diff for this file is too large to render. See raw diff
 
app/math_skills.py CHANGED
@@ -1,11 +1,12 @@
1
  """The 9th-grade algebra skill graph for the math path (`math_algebra9.json`).
2
 
3
- Each skill names its prerequisites and a sample question. Choice samples identify mistakes;
4
- open-ended samples name their type and revision and accept a photographed answer.
5
  A mistake sends the student back to the skill it comes from (`back`), or keeps them on the same skill (`null`).
6
- The units follow the Ministry of Education's 9th-grade teaching schedule for 2026/27 (`docs/prisa_9A.md`), with
7
- ten skills in each; `month` is when the schedule teaches a skill. The questions are written for the app, and
8
- every skill, link and mistake still needs a teacher's review.
 
9
 
10
  `problems` is run by the tests and on loading: a broken graph is refused before a student meets it.
11
  """
@@ -26,6 +27,43 @@ def question_version(sample: dict) -> str:
26
  separators=(",", ":")).encode()).hexdigest()
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  def problems(graph: dict) -> list[str]:
30
  """What is wrong with a graph: unknown units, skills or mistakes, a cycle, a link implied by another,
31
  a question without exactly one right answer, or a mistake that sends the student to a skill that is not before it."""
@@ -53,32 +91,16 @@ def problems(graph: dict) -> list[str]:
53
  return [f"cycle: {cycle}"]
54
  for s in skills.values():
55
  out += [f"{s['id']}: {p} is implied by {q}" for p in s["prereqs"] for q in s["prereqs"] if p != q and p in below[q]]
56
- sample = s["sample"]
57
- kind = sample.get("type", "multiple-choice")
58
- if kind not in ("open-ended", "multiple-choice"):
59
- out.append(f"{s['id']}: unknown question type {kind}")
60
  continue
61
- if kind == "open-ended":
62
- if "choices" in sample:
63
- out.append(f"{s['id']}: open-ended questions cannot have choices")
64
- if not isinstance(sample.get("question"), str) or not sample["question"].strip():
65
- out.append(f"{s['id']}: question text is required")
66
- if type(sample.get("version")) is not int or sample["version"] < 1:
67
- out.append(f"{s['id']}: positive question version is required")
68
  continue
69
- choices = sample.get("choices", [])
70
- if sum(bool(c.get("correct")) for c in choices) != 1:
71
- out.append(f"{s['id']}: needs exactly one correct choice")
72
- if len({c["text"] for c in choices}) != len(choices):
73
- out.append(f"{s['id']}: two choices read the same")
74
- for c in choices:
75
- m = c.get("mistake")
76
- if c.get("correct"):
77
- continue
78
- if m not in mistakes:
79
- out.append(f"{s['id']}: unknown mistake {m}")
80
- elif (back := mistakes[m]["back"]) and back != s["id"] and back not in below[s["id"]]:
81
- out.append(f"{s['id']}: mistake {m} sends back to {back}, which is not before it")
82
  out += [f"mistake {k}: unknown skill {m['back']}" for k, m in mistakes.items() if m["back"] and m["back"] not in skills]
83
  return out
84
 
@@ -98,9 +120,12 @@ def practice_graph(answer_scans_enabled: bool) -> dict:
98
  The original choice sample is served when authenticated storage is disabled.
99
  """
100
  graph = copy.deepcopy(algebra9())
 
101
  for skill in graph["skills"]:
 
102
  fallback = skill.pop("fallback_sample", None)
103
  if not answer_scans_enabled and fallback is not None:
104
  skill["sample"] = fallback
105
- skill["question_version"] = question_version(skill["sample"])
 
106
  return graph
 
1
  """The 9th-grade algebra skill graph for the math path (`math_algebra9.json`).
2
 
3
+ Each skill names its prerequisites and either one `sample` question or a list of multiple-choice `questions`.
4
+ Choice samples identify mistakes; open-ended samples name their type and revision and accept a photographed answer.
5
  A mistake sends the student back to the skill it comes from (`back`), or keeps them on the same skill (`null`).
6
+ The units follow the Ministry of Education's 9th-grade teaching schedule for 2026/27 (`docs/prisa_9A.md`).
7
+ Units already split by the schedule's topics (ื˜ื›ื ื™ืงื” ืืœื’ื‘ืจื™ืช, ืคื•ื ืงืฆื™ื” ืจื™ื‘ื•ืขื™ืช) have ten questions per skill;
8
+ the other units still have ten skills with one question each. `month` is when the schedule teaches a skill.
9
+ The questions are written for the app, and every skill, link and mistake still needs a teacher's review.
10
 
11
  `problems` is run by the tests and on loading: a broken graph is refused before a student meets it.
12
  """
 
27
  separators=(",", ":")).encode()).hexdigest()
28
 
29
 
30
+ def questions(skill: dict) -> list[dict]:
31
+ """A skill's questions: its list of questions, or its one sample."""
32
+ return skill["questions"] if "questions" in skill else [skill["sample"]]
33
+
34
+
35
+ def sample_problems(where: str, sid: str, sample: dict, listed: bool, mistakes: dict, below: dict) -> list[str]:
36
+ kind = sample.get("type", "multiple-choice")
37
+ if kind not in ("open-ended", "multiple-choice"):
38
+ return [f"{where}: unknown question type {kind}"]
39
+ if kind == "open-ended":
40
+ if listed:
41
+ return [f"{where}: a question list holds multiple-choice questions only"]
42
+ out = []
43
+ if "choices" in sample:
44
+ out.append(f"{where}: open-ended questions cannot have choices")
45
+ if not isinstance(sample.get("question"), str) or not sample["question"].strip():
46
+ out.append(f"{where}: question text is required")
47
+ if type(sample.get("version")) is not int or sample["version"] < 1:
48
+ out.append(f"{where}: positive question version is required")
49
+ return out
50
+ out = []
51
+ choices = sample.get("choices", [])
52
+ if sum(bool(c.get("correct")) for c in choices) != 1:
53
+ out.append(f"{where}: needs exactly one correct choice")
54
+ if len({c["text"] for c in choices}) != len(choices):
55
+ out.append(f"{where}: two choices read the same")
56
+ for c in choices:
57
+ m = c.get("mistake")
58
+ if c.get("correct"):
59
+ continue
60
+ if m not in mistakes:
61
+ out.append(f"{where}: unknown mistake {m}")
62
+ elif (back := mistakes[m]["back"]) and back != sid and back not in below[sid]:
63
+ out.append(f"{where}: mistake {m} sends back to {back}, which is not before it")
64
+ return out
65
+
66
+
67
  def problems(graph: dict) -> list[str]:
68
  """What is wrong with a graph: unknown units, skills or mistakes, a cycle, a link implied by another,
69
  a question without exactly one right answer, or a mistake that sends the student to a skill that is not before it."""
 
91
  return [f"cycle: {cycle}"]
92
  for s in skills.values():
93
  out += [f"{s['id']}: {p} is implied by {q}" for p in s["prereqs"] for q in s["prereqs"] if p != q and p in below[q]]
94
+ if ("questions" in s) == ("sample" in s):
95
+ out.append(f"{s['id']}: needs a sample or a list of questions, not both")
 
 
96
  continue
97
+ listed = "questions" in s
98
+ if listed and (not isinstance(s["questions"], list) or not s["questions"]):
99
+ out.append(f"{s['id']}: the question list is empty")
 
 
 
 
100
  continue
101
+ for n, sample in enumerate(questions(s), 1):
102
+ out += sample_problems(f"{s['id']} question {n}" if listed else s["id"], s["id"], sample, listed,
103
+ mistakes, below)
 
 
 
 
 
 
 
 
 
 
104
  out += [f"mistake {k}: unknown skill {m['back']}" for k, m in mistakes.items() if m["back"] and m["back"] not in skills]
105
  return out
106
 
 
120
  The original choice sample is served when authenticated storage is disabled.
121
  """
122
  graph = copy.deepcopy(algebra9())
123
+ subject = {unit["id"]: unit["subject"] for unit in graph["units"]}
124
  for skill in graph["skills"]:
125
+ skill["subject"] = subject[skill["unit"]] # a subunit belongs to its unit's subject
126
  fallback = skill.pop("fallback_sample", None)
127
  if not answer_scans_enabled and fallback is not None:
128
  skill["sample"] = fallback
129
+ if "sample" in skill: # identifies an open-ended question's uploaded answers
130
+ skill["question_version"] = question_version(skill["sample"])
131
  return graph
static/math.css CHANGED
@@ -20,6 +20,9 @@ html.math-on, html.math-on body{overflow:hidden}
20
  #mathView .u-a{--uc:var(--u-a)} #mathView .u-z{--uc:var(--u-z)} #mathView .u-r{--uc:var(--u-r)} #mathView .u-h{--uc:var(--u-h)}
21
  #mathView .u-s{--uc:var(--u-s)} #mathView .u-m{--uc:var(--u-m)} #mathView .u-n{--uc:var(--u-n)} #mathView .u-p{--uc:var(--u-p)}
22
  #mathView .u-w{--uc:var(--u-w)} #mathView .u-d{--uc:var(--u-d)}
 
 
 
23
  #mathView h1,#mathView h2,#mathView h3{font-family:var(--f-display);font-weight:400;margin:0;text-wrap:balance}
24
  #mathView button{font-family:var(--f-body)}
25
  #mathView button:focus-visible{outline:3px solid var(--mx-accent);outline-offset:2px}
@@ -253,3 +256,18 @@ html.math-on, html.math-on body{overflow:hidden}
253
  .mx-grade h4,.mx-grade p{margin:0}.mx-grade-correct{border-color:var(--ok);background:var(--ok-bg)}
254
  .mx-grade summary{cursor:pointer;color:var(--mx-accent)}.mx-grade details p{margin-top:10px}
255
  .mx-grade-solution{white-space:pre-wrap}.mx-grade small{color:var(--muted)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  #mathView .u-a{--uc:var(--u-a)} #mathView .u-z{--uc:var(--u-z)} #mathView .u-r{--uc:var(--u-r)} #mathView .u-h{--uc:var(--u-h)}
21
  #mathView .u-s{--uc:var(--u-s)} #mathView .u-m{--uc:var(--u-m)} #mathView .u-n{--uc:var(--u-n)} #mathView .u-p{--uc:var(--u-p)}
22
  #mathView .u-w{--uc:var(--u-w)} #mathView .u-d{--uc:var(--u-d)}
23
+ /* units split off an older unit keep its colour */
24
+ #mathView .u-i{--uc:var(--u-k)} #mathView .u-l,#mathView .u-o{--uc:var(--u-a)} #mathView .u-v{--uc:var(--u-e)}
25
+ #mathView .u-g{--uc:var(--u-z)} #mathView .u-b,#mathView .u-c{--uc:var(--u-m)}
26
  #mathView h1,#mathView h2,#mathView h3{font-family:var(--f-display);font-weight:400;margin:0;text-wrap:balance}
27
  #mathView button{font-family:var(--f-body)}
28
  #mathView button:focus-visible{outline:3px solid var(--mx-accent);outline-offset:2px}
 
256
  .mx-grade h4,.mx-grade p{margin:0}.mx-grade-correct{border-color:var(--ok);background:var(--ok-bg)}
257
  .mx-grade summary{cursor:pointer;color:var(--mx-accent)}.mx-grade details p{margin-top:10px}
258
  .mx-grade-solution{white-space:pre-wrap}.mx-grade small{color:var(--muted)}
259
+ .mx-qprog{display:flex;justify-content:space-between;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
260
+ .mx-qdots{display:flex;gap:4px;flex-wrap:wrap}
261
+ .mx-qdots i{width:9px;height:9px;border-radius:50%;background:var(--line)}
262
+ .mx-qdots i.ok{background:var(--ok)}
263
+ .mx-qdots i.now{outline:2px solid var(--mx-accent);outline-offset:1px}
264
+ .mx-count{font:700 11px/1 var(--f-body);padding:4px 7px;border-radius:999px;background:var(--bg);color:var(--muted)}
265
+ .mx-content-subject{display:flex;flex-direction:column;gap:8px;margin-top:14px}
266
+ .mx-content-subject>h5{display:flex;align-items:baseline;gap:8px;margin:0;font-size:18px;color:var(--exam,var(--mx-accent))}
267
+ .mx-content-subject>h5 small{color:var(--muted);font-size:13px;font-weight:500}
268
+ .mx-content-unit{border:1px solid var(--line);border-radius:12px;background:var(--bg)}
269
+ .mx-content-unit summary{display:flex;flex-direction:column;gap:3px;padding:12px;cursor:pointer}
270
+ .mx-content-unit summary small,.mx-content-unit li small{color:var(--muted);font-size:12px}
271
+ .mx-content-unit ol{margin:0;padding:0 34px 12px 12px;display:flex;flex-direction:column;gap:8px}
272
+ .mx-content-unit li span{display:block}
273
+ .mx-pick-subject{margin:10px 0 6px;font-size:14px;color:var(--mx-accent)}
static/math.js CHANGED
@@ -2,8 +2,10 @@
2
  The skill graph comes from /api/math/skills (app/math_skills.py). A skill is open once every skill it needs is
3
  done, and the first open one is where the student goes on. A student may still open a locked skill: without a
4
  placement test, one who already knows the foundations should not have to answer them first.
5
- Each skill has one sample question, and answering it correctly marks the skill done. Progress is kept on
6
- this device, per account (mathly.progress.v1:<email>). */
 
 
7
  (()=>{
8
  const css=document.createElement("link"); css.rel="stylesheet"; css.href=document.currentScript.src.replace("math.js","math.css");
9
  document.head.append(css);
@@ -23,7 +25,7 @@ const key=()=>"mathly.progress.v1:"+(ME_EMAIL||"guest");
23
  const load=()=>{ try{ return JSON.parse(localStorage.getItem(key())||"{}"); }catch{ return {}; } };
24
  const save=()=>{ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} };
25
 
26
- let G=null, BY={}, POST={}, DEPTH={}, ORDER=[], P={done:{},tries:{}}, root=null, path=null, sheet=null, cur=null, back=null, ACTIVE_SUBJECT="";
27
  let picker=null, PH=null; // the photo file picker, and the photographed problem on the sheet (see photo())
28
  let advanceTimer=null;
29
  let phonePair=null;
@@ -36,10 +38,16 @@ function cancelAdvance(){ clearTimeout(advanceTimer); advanceTimer=null; }
36
  function index(graph){
37
  G=graph; BY=Object.fromEntries(G.skills.map(s=>[s.id,s])); POST=Object.fromEntries(G.skills.map(s=>[s.id,[]]));
38
  G.skills.forEach(s=>s.prereqs.forEach(p=>POST[p].push(s.id)));
39
- const depth=id=>DEPTH[id]??(DEPTH[id]=BY[id].prereqs.length?1+Math.max(...BY[id].prereqs.map(depth)):0);
40
- G.skills.forEach(s=>depth(s.id));
41
- // the path's order: unit by unit, and in a unit each skill after the ones it needs
42
- ORDER=G.units.flatMap(u=>G.skills.filter(s=>s.unit===u.id).sort((a,b)=>DEPTH[a.id]-DEPTH[b.id]).map(s=>s.id));
 
 
 
 
 
 
43
  }
44
  // the units the student's class studies (its "lessons"); a teacher, or an account in no class, has every unit.
45
  // A skill of a unit the class does not study counts as known: it does not lock the skills that need it.
@@ -50,6 +58,11 @@ const shownUnits=()=>{
50
  let SHOWN=new Set();
51
  const status=id=>P.done[id]?"done":BY[id].prereqs.every(p=>P.done[p]||!SHOWN.has(BY[p].unit))?"open":"locked";
52
  const next=()=>ORDER.find(id=>SHOWN.has(BY[id].unit)&&status(id)==="open")||null;
 
 
 
 
 
53
 
54
  async function show(){
55
  document.documentElement.classList.add("math-on");
@@ -70,7 +83,7 @@ async function show(){
70
  try{ const r=await fetch("/api/math/skills"); if(!r.ok) throw new Error(`ื”ืฉืจืช ืขื ื” ${r.status}`); index(await r.json()); }
71
  catch(e){ path.innerHTML=`<p class="mx-note">ืœื ื”ืฆืœื—ื ื• ืœื˜ืขื•ืŸ ืืช ื”ืžืกืœื•ืœ: ${esc(e.message)}. ืจืขื ื ื• ืืช ื”ืขืžื•ื“ ื•ื ืกื• ืฉื•ื‘.</p>`; return; }
72
  }
73
- P=Object.assign({done:{},tries:{}},load()); SHOWN=new Set(shownUnits().map(u=>u.id));
74
  subjectOptions().length?drawSubjectPicker():selectSubject("ืžืชืžื˜ื™ืงื”");
75
  window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,onGraded:({questionId,grading})=>{
76
  if(grading.result.verdict!=="correct"||P.done[questionId])return;
@@ -119,10 +132,10 @@ function draw(){
119
  const ids=ORDER.filter(id=>BY[id].unit===u.id);
120
  return `<section class="mx-unit u-${u.id}"><div class="mx-uhead"><h2>${esc(u.name)}</h2><span>${esc(u.source)}${u.when?" ยท "+esc(u.when):""}</span></div>
121
  <div class="mx-trail">${ids.map((id,i)=>{
122
- const s=BY[id], st=status(id), here=id===nxt;
123
  return `<button class="mx-node st-${st}${here?" here":""}" data-id="${id}" aria-label="${esc(plain(s.name))}, ${st==="done"?"ื”ื•ืฉืœืžื”":st==="open"?"ืคืชื•ื—ื”":"ื ืขื•ืœื”"}">
124
  <span class="mx-dot">${st==="done"?"โœ“":i+1}</span>
125
- <span class="mx-lbl"><b>${fmt(s.name)}</b><small>${s.core?'<span class="mx-core">ืœื™ื‘ื” 5 ื™ื—ืดืœ</span>':""}${here?'<span class="mx-go">ืžืžืฉื™ื›ื™ื ื›ืืŸ</span>':""}</small></span></button>`;
126
  }).join("")}</div></section>`;
127
  }).join("");
128
  path.innerHTML=`<header class="mx-top"><div><h1>ื”ืžืกืœื•ืœ ืฉืœื™</h1><p class="mx-sub">ืืœื’ื‘ืจื” ื•ื’ืื•ืžื˜ืจื™ื” ื›ื™ืชื” ื˜ืณ ยท 5 ื™ื—ืดืœ${cls}</p></div>
@@ -135,17 +148,19 @@ function draw(){
135
  <button class="mx-photo" data-act="photo"><b>ืฆื™ืœื•ื ืชืจื’ื™ืœ</b><small>ืฆืœืžื• ืชืจื’ื™ืœ ืžื”ืžื—ื‘ืจืช ืื• ืžื”ื“ืฃ, ื•ื ืขื–ื•ืจ ืœื›ื ืœืคืชื•ืจ ืื•ืชื• ืฆืขื“ ืื—ืจ ืฆืขื“</small></button>
136
  <button class="mx-photo" data-act="phone"><b>ืฆื™ืœื•ื ื‘ื˜ืœืคื•ืŸ, ืคืชืจื•ืŸ ื‘ืžื—ืฉื‘</b><small>ืกืจืงื• ืงื•ื“ ื‘ื˜ืœืคื•ืŸ, ืฆืœืžื• ืชืจื’ื™ืœ ื•ืจืื• ืืช ื”ืชืฉื•ื‘ื” ื›ืืŸ ืื•ื˜ื•ืžื˜ื™ืช</small></button>
137
  ${trail}
138
- <p class="mx-note">ื‘ืฉืœื‘ ื”ื–ื” ื™ืฉ ืฉืืœื” ืื—ืช ืœื“ื•ื’ืžื” ืœื›ืœ ืžื™ื•ืžื ื•ืช. ื”ื”ืชืงื“ืžื•ืช ื ืฉืžืจืช ื‘ืžื›ืฉื™ืจ ื”ื–ื”.</p>`;
139
  }
140
 
141
  function chips(ids){
142
- return ids.length?ids.map(id=>`<button class="mx-chip u-${BY[id].unit}${P.done[id]?" is-done":""}" data-id="${id}">${P.done[id]?"โœ“ ":""}${esc(BY[id].short)}</button>`).join(""):'<em>ืื™ืŸ</em>';
143
  }
144
  function open(id){
145
  stopPhone();
146
  cancelAdvance();
147
- const s=BY[id], st=status(id), unit=G.units.find(u=>u.id===s.unit), openEnded=s.sample.type==="open-ended";
148
- const options=s.sample.choices||[],rot=options.length?ORDER.indexOf(id)%options.length:0;
 
 
149
  const choices=options.slice(rot).concat(options.slice(0,rot)), missing=s.prereqs.filter(p=>!P.done[p]&&SHOWN.has(BY[p].unit));
150
  if(!cur) back=document.activeElement;
151
  cur=id;
@@ -156,8 +171,9 @@ function open(id){
156
  <button class="mx-x" data-act="close" aria-label="ืกื’ื™ืจื”">โœ•</button></div>
157
  <p>${fmt(s.description)}</p>
158
  ${st==="locked"?`<div class="mx-hint">ืžื•ืžืœืฅ ืœืขื‘ื•ืจ ืงื•ื“ื ืขืœ: <span class="mx-chips">${chips(missing)}</span></div>`:""}
159
- <div class="mx-q"><p>${fmt(s.sample.question)}</p>${s.sample.graph?plot(s.sample.graph):""}${s.sample.diagram?geometryDiagram(s.sample.diagram):""}
160
- ${openEnded?'<div class="mx-answer"></div>':`<div class="mx-choices">${choices.map((c,i)=>`<button data-choice="${i}" data-m="${c.correct?"":esc(c.mistake)}">${fmt(c.text)}</button>`).join("")}</div>`}
 
161
  <div class="mx-fb" aria-live="polite"></div></div>
162
  <div class="mx-sec"><h4>ื“ื•ืจืฉืช</h4><div class="mx-chips">${chips(s.prereqs)}</div></div>
163
  <div class="mx-sec"><h4>ืคื•ืชื—ืช</h4><div class="mx-chips">${chips(POST[id])}</div></div>
@@ -168,36 +184,54 @@ function open(id){
168
  }
169
  function closeSheet(){ stopPhone(); cancelAdvance(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  function answer(btn){
172
  if(btn.disabled) return;
173
- const id=cur, m=btn.dataset.m, fb=sheet.querySelector(".mx-fb");
174
- sheet.querySelectorAll("[data-choice]").forEach(b=>b.classList.remove("right","wrong"));
 
175
  btn.classList.add(m?"wrong":"right");
176
  P.tries[id]=(P.tries[id]||0)+1;
 
177
  if(!m){
178
- const first=!P.done[id]; P.done[id]=P.done[id]||new Date().toISOString(); save(); draw();
179
- const nxt=next();
180
- sheet.querySelectorAll("[data-choice]").forEach(b=>b.disabled=true);
181
- fb.innerHTML=`<div class="mx-ok"><b>ื ื›ื•ืŸ!</b>${first?"<span>ื”ืžื™ื•ืžื ื•ืช ืกื•ืžื ื” ื›ื”ื•ืฉืœืžื”.</span>":""}
182
- ${nxt&&nxt!==id?`<span class="mx-countdown"></span><button class="mx-btn solid" data-id="${nxt}">ื”ืžืฉืš ืขื›ืฉื™ื•: ${esc(BY[nxt].short)}</button>`:"<span>ืกื™ื™ืžืชื ืืช ื›ืœ ื”ืžืกืœื•ืœ. ื›ืœ ื”ื›ื‘ื•ื“!</span>"}</div>`;
183
- if(nxt&&nxt!==id){
184
- let seconds=3;
185
- const countdown=fb.querySelector(".mx-countdown");
186
- const tick=()=>{
187
- if(cur!==id||sheet.hidden||root.hidden) return cancelAdvance();
188
- if(seconds===0) return open(nxt);
189
- countdown.textContent=`ื”ืชืจื’ื™ืœ ื”ื‘ื ื‘ืขื•ื“ ${seconds}โ€ฆ`;
190
- seconds--;
191
- advanceTimer=setTimeout(tick,1000);
192
- };
193
- tick();
194
  }
 
 
 
 
 
 
195
  return;
196
  }
197
  save();
198
  const M=G.mistakes[m], to=M.back&&M.back!==id?M.back:null;
 
 
199
  fb.innerHTML=`<div class="mx-bad"><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
200
- ${to?`<button class="mx-btn" data-id="${to}">ื—ื–ืจื” ืืœ: ${esc(BY[to].short)}</button>`:"<span class=\"mx-small\">ืืคืฉืจ ืœื‘ื—ื•ืจ ืชืฉื•ื‘ื” ืื—ืจืช.</span>"}</div>`;
 
201
  }
202
 
203
  function onClick(e){
@@ -228,7 +262,7 @@ let MANAGED_CLASSES=[], MANAGE_MODE="classes", MANAGE_BUSY=false;
228
  const manageTabs=[
229
  ["classes","ื›ื™ืชื•ืช","ืคืชื™ื—ื”, ืขืจื™ื›ื” ื•ืžื—ื™ืงื”"],
230
  ["students","ืชืœืžื™ื“ื™ื","ืจืฉื™ืžื” ื•ืžืกื ืŸ ื›ื™ืชื•ืช"],
231
- ["content","ืชื•ื›ืŸ","ื ื•ืฉืื™ื, ืกืจื˜ื•ื ื™ื ื•ื˜ืงืกื˜ื™ื"],
232
  ["flags","ื ื™ื˜ื•ืจ","ืคืขื™ืœื•ืช ื•ื“ื™ื•ื•ื—ื™ ืฆื™ื•ื ื™ื"],
233
  ["access","ื”ืจืฉืื•ืช","ืชืคืงื™ื“ื™ื ื•ื’ื™ืฉื”"]
234
  ];
@@ -309,10 +343,7 @@ function showManageMode(mode){
309
  box.innerHTML=`<div class="mx-manage-title"><h4>ืชืœืžื™ื“ื™ื</h4><span>${students.size} ืชืœืžื™ื“ื™ื ยท ${MANAGED_CLASSES.length} ื›ื™ืชื•ืช</span></div>${students.size?`<div class="mx-manage-list">${[...students].map(([email,classes])=>`<article><b dir="ltr">${esc(email)}</b><small>${esc(classes.join(" ยท "))}</small></article>`).join("")}</div>`:'<p class="mx-note">ืขื“ื™ื™ืŸ ืœื ื ื•ืกืคื• ืชืœืžื™ื“ื™ื ืœื›ื™ืชื•ืช.</p>'}`;
310
  return;
311
  }
312
- if(mode==="content"){
313
- box.innerHTML=`<div class="mx-manage-title"><h4>ืชื•ื›ืŸ ืœื™ืžื•ื“ื™</h4><span>${G.units.length} ื ื•ืฉืื™ื ื‘ืžืกืœื•ืœ</span></div><div class="mx-manage-list">${G.units.map(unit=>{const skills=G.skills.filter(skill=>skill.unit===unit.id);const count=MANAGED_CLASSES.filter(row=>(row.lessons||[]).includes(unit.id)).length;return `<article class="u-${unit.id}"><b>${esc(unit.name)}</b><small>${skills.length} ืžื™ื•ืžื ื•ื™ื•ืช ยท ืคืขื™ืœ ื‘ึพ${count} ื›ื™ืชื•ืช</small></article>`}).join("")}</div>`;
314
- return;
315
- }
316
  if(mode==="flags"){
317
  const done=Object.keys(P.done||{}).length, tries=Object.values(P.tries||{}).reduce((sum,n)=>sum+(+n||0),0);
318
  box.innerHTML=`<div class="mx-manage-title"><h4>ื ื™ื˜ื•ืจ</h4><span>ืคืขื™ืœื•ืช ื•ื“ื™ื•ื•ื—ื™ ืฆื™ื•ื ื™ื</span></div><div class="mx-manage-stats"><article><b>${MANAGED_CLASSES.length}</b><small>ื›ื™ืชื•ืช ืคืขื™ืœื•ืช</small></article><article><b>${done}</b><small>ืžื™ื•ืžื ื•ื™ื•ืช ืฉื”ื•ืฉืœืžื• ื‘ืžื›ืฉื™ืจ ื–ื”</small></article><article><b>${tries}</b><small>ื ื™ืกื™ื•ื ื•ืช ื‘ืžื›ืฉื™ืจ ื–ื”</small></article></div><p class="mx-note">ื ืชื•ื ื™ ื”ืชืงื“ืžื•ืช ื›ื™ืชืชื™ื™ื ื™ื•ืคื™ืขื• ื›ืืŸ ื›ืืฉืจ ืฉื™ืจื•ืช ื“ื™ื•ื•ื—ื™ ื”ืฆื™ื•ื ื™ื ื™ื—ื•ื‘ืจ.</p>`;
@@ -326,6 +357,26 @@ function showManageMode(mode){
326
  box.innerHTML='<div class="mx-manage-title"><h4>ืžืขื‘ื“ื”</h4><span>ื›ืœื™ื ืœื‘ื“ื™ืงื” ื•ืคื™ืชื•ื—</span></div><p class="mx-note">ื›ืœื™ ื”ืžืขื‘ื“ื” ืœืžืชืžื˜ื™ืงื” ื™ืชื•ื•ืกืคื• ื›ืืŸ. ืืคืฉืจ ืœื—ื–ื•ืจ ืœื›ืœ ืื–ื•ืจ ื ื™ื”ื•ืœ ืžื”ื›ืจื˜ื™ืกื™ื•ืช ืœืžืขืœื”.</p>';
327
  }
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  function drawManagedClasses(box){
330
  const classes=MANAGED_CLASSES;
331
  box.innerHTML=`<div class="mx-manage-title"><h4>ื›ื™ืชื•ืช</h4><span>ืคืชื™ื—ื”, ืขืจื™ื›ื” ื•ืžื—ื™ืงื”</span></div>
@@ -354,7 +405,7 @@ function openClassEditor(row){
354
  const selected=new Set(row?.lessons||G.units.map(unit=>unit.id));
355
  box.innerHTML=`<div class="mx-manage-title"><h4>${creating?"ื›ื™ืชื” ื—ื“ืฉื”":`ืขืจื™ื›ืช ${esc(row.name)}`}</h4><button class="mx-btn" type="button" data-class-action="cancel">ื—ื–ืจื” ืœื›ื™ืชื•ืช</button></div>
356
  <form class="mx-class-form"><label>ืฉื ื”ื›ื™ืชื”<input name="name" required maxlength="80" autocomplete="off" placeholder="ืœืžืฉืœ: ื˜ืณ 1" value="${esc(row?.name||"")}"></label>
357
- <fieldset><legend>ื™ื—ื™ื“ื•ืช ื”ืœื™ืžื•ื“<small>ื”ืชืœืžื™ื“ื™ื ื™ืจืื• ื‘ืžืกืœื•ืœ ืจืง ืืช ื”ืžื™ื•ืžื ื•ื™ื•ืช ืฉืœ ื”ื™ื—ื™ื“ื•ืช ืฉืกื•ืžื ื•. ืžืงืฆื•ืข ื”ื™ื—ื™ื“ื” ืงื•ื‘ืข ืื™ืœื• ืžื‘ื—ื ื™ื ื™ื•ืฆื’ื• ื‘ืžืกืœื•ืœ.</small></legend><div class="mx-unit-picks">${G.units.map(unit=>`<label class="u-${unit.id}"><input type="checkbox" name="unit" value="${esc(unit.id)}"${selected.has(unit.id)?" checked":""}><span>${esc(unit.name)}<small>${esc(unit.subject)} ยท ${G.skills.filter(skill=>skill.unit===unit.id).length} ืžื™ื•ืžื ื•ื™ื•ืช</small></span></label>`).join("")}</div></fieldset>
358
  <label>ื”ืชืœืžื™ื“ื™ื<small>ื›ืชื•ื‘ืช ื“ื•ืืดืœ ืื—ืช ื‘ื›ืœ ืฉื•ืจื”. ืชืœืžื™ื“ ื™ื›ื•ืœ ืœื”ืฉืชื™ื™ืš ืœื™ื•ืชืจ ืžื›ื™ืชื” ืื—ืช.</small><textarea name="emails" dir="ltr" spellcheck="false" placeholder="student@example.com">${esc((row?.emails||[]).join("\n"))}</textarea></label>
359
  <label>ืžื•ืจื™ ื”ื›ื™ืชื”<small>ื›ืชื•ื‘ืช ื“ื•ืืดืœ ืื—ืช ื‘ื›ืœ ืฉื•ืจื”. ืœืžื•ืจื™ื ืชื”ื™ื” ื’ื™ืฉื” ืœื›ื™ืชื” ื”ื–ื•.</small><textarea name="teachers" dir="ltr" spellcheck="false" placeholder="teacher@example.com">${esc((row?.teachers||[]).join("\n"))}</textarea></label>
360
  <div class="mx-class-form-actions"><button class="mx-btn solid" type="submit">${creating?"ืคืชื™ื—ืช ื”ื›ื™ืชื”":"ืฉืžื™ืจืช ื”ืฉื™ื ื•ื™ื™ื"}</button><button class="mx-btn" type="button" data-class-action="cancel">ื‘ื™ื˜ื•ืœ</button><span role="status" aria-live="polite"></span></div></form>`;
 
2
  The skill graph comes from /api/math/skills (app/math_skills.py). A skill is open once every skill it needs is
3
  done, and the first open one is where the student goes on. A student may still open a locked skill: without a
4
  placement test, one who already knows the foundations should not have to answer them first.
5
+ A skill has one sample question or a list of questions. It is done once every one of its questions has been
6
+ answered correctly; after each answer the student moves to the next unsolved question, so a missed question comes
7
+ back after the others. Progress is kept on this device, per account (mathly.progress.v1:<email>): `done` per
8
+ skill, and `solved` (indexes answered correctly) and `at` (the question to show) for skills with a list. */
9
  (()=>{
10
  const css=document.createElement("link"); css.rel="stylesheet"; css.href=document.currentScript.src.replace("math.js","math.css");
11
  document.head.append(css);
 
25
  const load=()=>{ try{ return JSON.parse(localStorage.getItem(key())||"{}"); }catch{ return {}; } };
26
  const save=()=>{ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} };
27
 
28
+ let G=null, BY={}, POST={}, ORDER=[], P={done:{},tries:{},solved:{},at:{}}, root=null, curQ=0, path=null, sheet=null, cur=null, back=null, ACTIVE_SUBJECT="";
29
  let picker=null, PH=null; // the photo file picker, and the photographed problem on the sheet (see photo())
30
  let advanceTimer=null;
31
  let phonePair=null;
 
38
  function index(graph){
39
  G=graph; BY=Object.fromEntries(G.skills.map(s=>[s.id,s])); POST=Object.fromEntries(G.skills.map(s=>[s.id,[]]));
40
  G.skills.forEach(s=>s.prereqs.forEach(p=>POST[p].push(s.id)));
41
+ // the path's order: unit by unit, and in a unit the order of the file (the teaching schedule's),
42
+ // except that a skill waits until the skills it needs in its unit are placed
43
+ ORDER=G.units.flatMap(u=>{
44
+ const left=G.skills.filter(s=>s.unit===u.id), placed=new Set(), out=[];
45
+ while(left.length){
46
+ const i=left.findIndex(s=>s.prereqs.every(p=>placed.has(p)||BY[p].unit!==u.id));
47
+ const [s]=left.splice(i,1); placed.add(s.id); out.push(s.id);
48
+ }
49
+ return out;
50
+ });
51
  }
52
  // the units the student's class studies (its "lessons"); a teacher, or an account in no class, has every unit.
53
  // A skill of a unit the class does not study counts as known: it does not lock the skills that need it.
 
58
  let SHOWN=new Set();
59
  const status=id=>P.done[id]?"done":BY[id].prereqs.every(p=>P.done[p]||!SHOWN.has(BY[p].unit))?"open":"locked";
60
  const next=()=>ORDER.find(id=>SHOWN.has(BY[id].unit)&&status(id)==="open")||null;
61
+ const QS=s=>s.questions||[s.sample];
62
+ const solvedOf=id=>new Set(P.solved[id]||[]);
63
+ // the question after `from` that is not solved yet; once all are solved, simply the next one
64
+ const nextQ=(id,from)=>{ const n=QS(BY[id]).length, got=solvedOf(id);
65
+ for(let k=1;k<=n;k++){ const j=(from+k)%n; if(!got.has(j)) return j; } return (from+1)%n; };
66
 
67
  async function show(){
68
  document.documentElement.classList.add("math-on");
 
83
  try{ const r=await fetch("/api/math/skills"); if(!r.ok) throw new Error(`ื”ืฉืจืช ืขื ื” ${r.status}`); index(await r.json()); }
84
  catch(e){ path.innerHTML=`<p class="mx-note">ืœื ื”ืฆืœื—ื ื• ืœื˜ืขื•ืŸ ืืช ื”ืžืกืœื•ืœ: ${esc(e.message)}. ืจืขื ื ื• ืืช ื”ืขืžื•ื“ ื•ื ืกื• ืฉื•ื‘.</p>`; return; }
85
  }
86
+ P=Object.assign({done:{},tries:{},solved:{},at:{}},load()); SHOWN=new Set(shownUnits().map(u=>u.id));
87
  subjectOptions().length?drawSubjectPicker():selectSubject("ืžืชืžื˜ื™ืงื”");
88
  window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,onGraded:({questionId,grading})=>{
89
  if(grading.result.verdict!=="correct"||P.done[questionId])return;
 
132
  const ids=ORDER.filter(id=>BY[id].unit===u.id);
133
  return `<section class="mx-unit u-${u.id}"><div class="mx-uhead"><h2>${esc(u.name)}</h2><span>${esc(u.source)}${u.when?" ยท "+esc(u.when):""}</span></div>
134
  <div class="mx-trail">${ids.map((id,i)=>{
135
+ const s=BY[id], st=status(id), here=id===nxt, n=QS(s).length, k=solvedOf(id).size;
136
  return `<button class="mx-node st-${st}${here?" here":""}" data-id="${id}" aria-label="${esc(plain(s.name))}, ${st==="done"?"ื”ื•ืฉืœืžื”":st==="open"?"ืคืชื•ื—ื”":"ื ืขื•ืœื”"}">
137
  <span class="mx-dot">${st==="done"?"โœ“":i+1}</span>
138
+ <span class="mx-lbl"><b>${fmt(s.name)}</b><small>${s.core?'<span class="mx-core">ืœื™ื‘ื” 5 ื™ื—ืดืœ</span>':""}${n>1&&st!=="done"&&k?`<span class="mx-count">${k}/${n}</span>`:""}${here?'<span class="mx-go">ืžืžืฉื™ื›ื™ื ื›ืืŸ</span>':""}</small></span></button>`;
139
  }).join("")}</div></section>`;
140
  }).join("");
141
  path.innerHTML=`<header class="mx-top"><div><h1>ื”ืžืกืœื•ืœ ืฉืœื™</h1><p class="mx-sub">ืืœื’ื‘ืจื” ื•ื’ืื•ืžื˜ืจื™ื” ื›ื™ืชื” ื˜ืณ ยท 5 ื™ื—ืดืœ${cls}</p></div>
 
148
  <button class="mx-photo" data-act="photo"><b>ืฆื™ืœื•ื ืชืจื’ื™ืœ</b><small>ืฆืœืžื• ืชืจื’ื™ืœ ืžื”ืžื—ื‘ืจืช ืื• ืžื”ื“ืฃ, ื•ื ืขื–ื•ืจ ืœื›ื ืœืคืชื•ืจ ืื•ืชื• ืฆืขื“ ืื—ืจ ืฆืขื“</small></button>
149
  <button class="mx-photo" data-act="phone"><b>ืฆื™ืœื•ื ื‘ื˜ืœืคื•ืŸ, ืคืชืจื•ืŸ ื‘ืžื—ืฉื‘</b><small>ืกืจืงื• ืงื•ื“ ื‘ื˜ืœืคื•ืŸ, ืฆืœืžื• ืชืจื’ื™ืœ ื•ืจืื• ืืช ื”ืชืฉื•ื‘ื” ื›ืืŸ ืื•ื˜ื•ืžื˜ื™ืช</small></button>
150
  ${trail}
151
+ <p class="mx-note">ืžื™ื•ืžื ื•ืช ืžืกื•ืžื ืช ื›ื”ื•ืฉืœืžื” ื›ืฉืขื•ื ื™ื ื ื›ื•ืŸ ืขืœ ื›ืœ ื”ืฉืืœื•ืช ืฉืœื”. ื”ื”ืชืงื“ืžื•ืช ื ืฉืžืจืช ื‘ืžื›ืฉื™ืจ ื”ื–ื”.</p>`;
152
  }
153
 
154
  function chips(ids){
155
+ return ids.length?ids.map(id=>`<button class="mx-chip u-${BY[id].unit}${P.done[id]?" is-done":""}" data-id="${id}">${P.done[id]?"โœ“ ":""}${fmt(BY[id].short)}</button>`).join(""):'<em>ืื™ืŸ</em>';
156
  }
157
  function open(id){
158
  stopPhone();
159
  cancelAdvance();
160
+ const s=BY[id], st=status(id), unit=G.units.find(u=>u.id===s.unit), all=QS(s), n=all.length;
161
+ curQ=Math.min(P.at[id]||0,n-1);
162
+ const q=all[curQ], openEnded=q.type==="open-ended", got=solvedOf(id);
163
+ const options=q.choices||[],rot=options.length?(ORDER.indexOf(id)+curQ)%options.length:0;
164
  const choices=options.slice(rot).concat(options.slice(0,rot)), missing=s.prereqs.filter(p=>!P.done[p]&&SHOWN.has(BY[p].unit));
165
  if(!cur) back=document.activeElement;
166
  cur=id;
 
171
  <button class="mx-x" data-act="close" aria-label="ืกื’ื™ืจื”">โœ•</button></div>
172
  <p>${fmt(s.description)}</p>
173
  ${st==="locked"?`<div class="mx-hint">ืžื•ืžืœืฅ ืœืขื‘ื•ืจ ืงื•ื“ื ืขืœ: <span class="mx-chips">${chips(missing)}</span></div>`:""}
174
+ <div class="mx-q">${n>1?`<div class="mx-qprog"><span>ืฉืืœื” ${curQ+1} ืžืชื•ืš ${n}</span><span class="mx-qdots" aria-label="ื ืคืชืจื• ${got.size} ืžืชื•ืš ${n}">${all.map((_,i)=>`<i class="${got.has(i)?"ok":""}${i===curQ?" now":""}"></i>`).join("")}</span></div>`:""}
175
+ <p>${fmt(q.question)}</p>${q.graph?plot(q.graph):""}${q.diagram?geometryDiagram(q.diagram):""}
176
+ ${openEnded?'<div class="mx-answer"></div>':`<div class="mx-choices">${choices.map((c,i)=>`<button data-choice="${i}" data-m="${c.correct?"":esc(c.mistake)}"${c.correct?" data-right":""}>${fmt(c.text)}</button>`).join("")}</div>`}
177
  <div class="mx-fb" aria-live="polite"></div></div>
178
  <div class="mx-sec"><h4>ื“ื•ืจืฉืช</h4><div class="mx-chips">${chips(s.prereqs)}</div></div>
179
  <div class="mx-sec"><h4>ืคื•ืชื—ืช</h4><div class="mx-chips">${chips(POST[id])}</div></div>
 
184
  }
185
  function closeSheet(){ stopPhone(); cancelAdvance(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
186
 
187
+ // counts down, then opens `target` (a skill id); stops if the student moved on in the meantime
188
+ function autoAdvance(id,target){
189
+ let seconds=3;
190
+ const countdown=sheet.querySelector(".mx-countdown");
191
+ const tick=()=>{
192
+ if(cur!==id||sheet.hidden||root.hidden) return cancelAdvance();
193
+ if(seconds===0) return open(target);
194
+ countdown.textContent=target===id?`ื”ืฉืืœื” ื”ื‘ืื” ื‘ืขื•ื“ ${seconds}โ€ฆ`:`ื”ืชืจื’ื™ืœ ื”ื‘ื ื‘ืขื•ื“ ${seconds}โ€ฆ`;
195
+ seconds--;
196
+ advanceTimer=setTimeout(tick,1000);
197
+ };
198
+ tick();
199
+ }
200
+
201
  function answer(btn){
202
  if(btn.disabled) return;
203
+ const id=cur, m=btn.dataset.m, fb=sheet.querySelector(".mx-fb"), n=QS(BY[id]).length, qi=curQ;
204
+ const buttons=sheet.querySelectorAll("[data-choice]");
205
+ buttons.forEach(b=>b.classList.remove("right","wrong"));
206
  btn.classList.add(m?"wrong":"right");
207
  P.tries[id]=(P.tries[id]||0)+1;
208
+ if(n>1) P.at[id]=nextQ(id,qi);
209
  if(!m){
210
+ const got=solvedOf(id); got.add(qi); P.solved[id]=[...got].sort((a,b)=>a-b);
211
+ const finished=got.size>=n, first=finished&&!P.done[id];
212
+ if(finished) P.done[id]=P.done[id]||new Date().toISOString();
213
+ save(); draw();
214
+ buttons.forEach(b=>b.disabled=true);
215
+ if(!P.done[id]){
216
+ fb.innerHTML=`<div class="mx-ok"><b>ื ื›ื•ืŸ!</b><span>ืคืชืจืชื ${got.size} ืžืชื•ืš ${n} ืฉืืœื•ืช ื‘ืžื™ื•ืžื ื•ืช ื”ื–ื•.</span>
217
+ <span class="mx-countdown"></span><button class="mx-btn solid" data-id="${id}">ืœืฉืืœื” ื”ื‘ืื”</button></div>`;
218
+ return autoAdvance(id,id);
 
 
 
 
 
 
 
219
  }
220
+ const nxt=next();
221
+ fb.innerHTML=`<div class="mx-ok"><b>ื ื›ื•ืŸ!</b>${first?`<span>${n>1?`ืคืชืจืชื ืืช ื›ืœ ${n} ื”ืฉืืœื•ืช. `:""}ื”ืžื™ื•ืžื ื•ืช ืกื•ืžื ื” ื›ื”ื•ืฉืœืžื”.</span>`:""}
222
+ ${nxt&&nxt!==id?`<span class="mx-countdown"></span><button class="mx-btn solid" data-id="${nxt}">ื”ืžืฉืš ืขื›ืฉื™ื•: ${fmt(BY[nxt].short)}</button>`
223
+ :nxt?"":"<span>ืกื™ื™ืžืชื ืืช ื›ืœ ื”ืžืกืœื•ืœ. ื›ืœ ื”ื›ื‘ื•ื“!</span>"}
224
+ ${n>1?`<button class="mx-btn" data-id="${id}">ืขื•ื“ ืฉืืœื” ื‘ืžื™ื•ืžื ื•ืช ื”ื–ื•</button>`:""}</div>`;
225
+ if(nxt&&nxt!==id) autoAdvance(id,nxt);
226
  return;
227
  }
228
  save();
229
  const M=G.mistakes[m], to=M.back&&M.back!==id?M.back:null;
230
+ // with a list of questions, a missed question is shown later again; trying again now would not count
231
+ if(n>1) buttons.forEach(b=>{ b.disabled=true; if(b.dataset.right!=null) b.classList.add("right"); });
232
  fb.innerHTML=`<div class="mx-bad"><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
233
+ ${n>1?`<span class="mx-small">ื”ืฉืืœื” ื”ื–ื• ืชื—ื–ื•ืจ ืื—ืจื™ ืฉืืจ ื”ืฉืืœื•ืช.</span><button class="mx-btn solid" data-id="${id}">ืœืฉืืœื” ื”ื‘ืื”</button>`:""}
234
+ ${to?`<button class="mx-btn" data-id="${to}">ื—ื–ืจื” ืืœ: ${fmt(BY[to].short)}</button>`:n>1?"":"<span class=\"mx-small\">ืืคืฉืจ ืœื‘ื—ื•ืจ ืชืฉื•ื‘ื” ืื—ืจืช.</span>"}</div>`;
235
  }
236
 
237
  function onClick(e){
 
262
  const manageTabs=[
263
  ["classes","ื›ื™ืชื•ืช","ืคืชื™ื—ื”, ืขืจื™ื›ื” ื•ืžื—ื™ืงื”"],
264
  ["students","ืชืœืžื™ื“ื™ื","ืจืฉื™ืžื” ื•ืžืกื ืŸ ื›ื™ืชื•ืช"],
265
+ ["content","ืชื•ื›ืŸ","ืžืงืฆื•ืขื•ืช, ื™ื—ื™ื“ื•ืช ื•ืชืชึพื ื•ืฉืื™ื"],
266
  ["flags","ื ื™ื˜ื•ืจ","ืคืขื™ืœื•ืช ื•ื“ื™ื•ื•ื—ื™ ืฆื™ื•ื ื™ื"],
267
  ["access","ื”ืจืฉืื•ืช","ืชืคืงื™ื“ื™ื ื•ื’ื™ืฉื”"]
268
  ];
 
343
  box.innerHTML=`<div class="mx-manage-title"><h4>ืชืœืžื™ื“ื™ื</h4><span>${students.size} ืชืœืžื™ื“ื™ื ยท ${MANAGED_CLASSES.length} ื›ื™ืชื•ืช</span></div>${students.size?`<div class="mx-manage-list">${[...students].map(([email,classes])=>`<article><b dir="ltr">${esc(email)}</b><small>${esc(classes.join(" ยท "))}</small></article>`).join("")}</div>`:'<p class="mx-note">ืขื“ื™ื™ืŸ ืœื ื ื•ืกืคื• ืชืœืžื™ื“ื™ื ืœื›ื™ืชื•ืช.</p>'}`;
344
  return;
345
  }
346
+ if(mode==="content") return drawContent(box);
 
 
 
347
  if(mode==="flags"){
348
  const done=Object.keys(P.done||{}).length, tries=Object.values(P.tries||{}).reduce((sum,n)=>sum+(+n||0),0);
349
  box.innerHTML=`<div class="mx-manage-title"><h4>ื ื™ื˜ื•ืจ</h4><span>ืคืขื™ืœื•ืช ื•ื“ื™ื•ื•ื—ื™ ืฆื™ื•ื ื™ื</span></div><div class="mx-manage-stats"><article><b>${MANAGED_CLASSES.length}</b><small>ื›ื™ืชื•ืช ืคืขื™ืœื•ืช</small></article><article><b>${done}</b><small>ืžื™ื•ืžื ื•ื™ื•ืช ืฉื”ื•ืฉืœืžื• ื‘ืžื›ืฉื™ืจ ื–ื”</small></article><article><b>${tries}</b><small>ื ื™ืกื™ื•ื ื•ืช ื‘ืžื›ืฉื™ืจ ื–ื”</small></article></div><p class="mx-note">ื ืชื•ื ื™ ื”ืชืงื“ืžื•ืช ื›ื™ืชืชื™ื™ื ื™ื•ืคื™ืขื• ื›ืืŸ ื›ืืฉืจ ืฉื™ืจื•ืช ื“ื™ื•ื•ื—ื™ ื”ืฆื™ื•ื ื™ื ื™ื—ื•ื‘ืจ.</p>`;
 
357
  box.innerHTML='<div class="mx-manage-title"><h4>ืžืขื‘ื“ื”</h4><span>ื›ืœื™ื ืœื‘ื“ื™ืงื” ื•ืคื™ืชื•ื—</span></div><p class="mx-note">ื›ืœื™ ื”ืžืขื‘ื“ื” ืœืžืชืžื˜ื™ืงื” ื™ืชื•ื•ืกืคื• ื›ืืŸ. ืืคืฉืจ ืœื—ื–ื•ืจ ืœื›ืœ ืื–ื•ืจ ื ื™ื”ื•ืœ ืžื”ื›ืจื˜ื™ืกื™ื•ืช ืœืžืขืœื”.</p>';
358
  }
359
 
360
+ // every subject a class meets: those with units, then those that so far only have exams
361
+ const contentSubjects=()=>[...new Set([...G.units.map(unit=>unit.subject),
362
+ ...MANAGED_CLASSES.flatMap(row=>(row.exams||[]).map(exam=>exam.subject))].filter(Boolean))];
363
+ const unitSkills=id=>ORDER.filter(sid=>BY[sid].unit===id).map(sid=>BY[sid]);
364
+ const questionCount=skills=>skills.reduce((sum,skill)=>sum+QS(skill).length,0);
365
+ function drawContent(box){
366
+ const subjects=contentSubjects();
367
+ box.innerHTML=`<div class="mx-manage-title"><h4>ืชื•ื›ืŸ ืœื™ืžื•ื“ื™</h4><span>${subjects.length} ืžืงืฆื•ืขื•ืช ยท ${G.units.length} ื™ื—ื™ื“ื•ืช ยท ${G.skills.length} ืชืชึพื ื•ืฉืื™ื ยท ${questionCount(G.skills)} ืฉืืœื•ืช</span></div>
368
+ ${subjects.map(subject=>{
369
+ const units=G.units.filter(unit=>unit.subject===subject), [icon,tone]=examLooks[subject]||["โ—","other"];
370
+ return `<section class="mx-content-subject exam-${tone}"><h5><span aria-hidden="true">${icon}</span>${esc(subject)}<small>${units.length?`${units.length} ื™ื—ื™ื“ื•ืช`:"ืขื•ื“ ืื™ืŸ ื™ื—ื™ื“ื•ืช ื‘ืžืกืœื•ืœ"}</small></h5>
371
+ ${units.map(unit=>{
372
+ const skills=unitSkills(unit.id), classes=MANAGED_CLASSES.filter(row=>(row.lessons||[]).includes(unit.id)).length;
373
+ return `<details class="mx-content-unit u-${unit.id}"><summary><b>${esc(unit.name)}</b>
374
+ <small>${unit.when?`${esc(unit.when)} ยท `:""}${skills.length} ืชืชึพื ื•ืฉืื™ื ยท ${questionCount(skills)} ืฉืืœื•ืช ยท ืคืขื™ืœ ื‘ึพ${classes} ื›ื™ืชื•ืช</small></summary>
375
+ <ol>${skills.map(skill=>`<li><span>${fmt(skill.name)}</span><small>${esc(skill.subject||subject)} โ€บ ${esc(unit.name)}${skill.month?` ยท ${esc(skill.month)}`:""} ยท ${QS(skill).length===1?"ืฉืืœื” ืื—ืช":`${QS(skill).length} ืฉืืœื•ืช`}</small></li>`).join("")}</ol></details>`;
376
+ }).join("")}</section>`;
377
+ }).join("")}`;
378
+ }
379
+
380
  function drawManagedClasses(box){
381
  const classes=MANAGED_CLASSES;
382
  box.innerHTML=`<div class="mx-manage-title"><h4>ื›ื™ืชื•ืช</h4><span>ืคืชื™ื—ื”, ืขืจื™ื›ื” ื•ืžื—ื™ืงื”</span></div>
 
405
  const selected=new Set(row?.lessons||G.units.map(unit=>unit.id));
406
  box.innerHTML=`<div class="mx-manage-title"><h4>${creating?"ื›ื™ืชื” ื—ื“ืฉื”":`ืขืจื™ื›ืช ${esc(row.name)}`}</h4><button class="mx-btn" type="button" data-class-action="cancel">ื—ื–ืจื” ืœื›ื™ืชื•ืช</button></div>
407
  <form class="mx-class-form"><label>ืฉื ื”ื›ื™ืชื”<input name="name" required maxlength="80" autocomplete="off" placeholder="ืœืžืฉืœ: ื˜ืณ 1" value="${esc(row?.name||"")}"></label>
408
+ <fieldset><legend>ื™ื—ื™ื“ื•ืช ื”ืœื™ืžื•ื“<small>ื”ืชืœืžื™ื“ื™ื ื™ืจืื• ื‘ืžืกืœื•ืœ ืจืง ืืช ื”ืžื™ื•ืžื ื•ื™ื•ืช ืฉืœ ื”ื™ื—ื™ื“ื•ืช ืฉืกื•ืžื ื•. ืžืงืฆื•ืข ื”ื™ื—ื™ื“ื” ืงื•ื‘ืข ืื™ืœื• ืžื‘ื—ื ื™ื ื™ื•ืฆื’ื• ื‘ืžืกืœื•ืœ.</small></legend>${[...new Set(G.units.map(unit=>unit.subject))].map(subject=>`<h6 class="mx-pick-subject">${esc(subject)}</h6><div class="mx-unit-picks">${G.units.filter(unit=>unit.subject===subject).map(unit=>{const skills=unitSkills(unit.id);return `<label class="u-${unit.id}"><input type="checkbox" name="unit" value="${esc(unit.id)}"${selected.has(unit.id)?" checked":""}><span>${esc(unit.name)}<small>${skills.length} ืชืชึพื ื•ืฉืื™ื ยท ${questionCount(skills)} ืฉืืœื•ืช</small></span></label>`}).join("")}</div>`).join("")}</fieldset>
409
  <label>ื”ืชืœืžื™ื“ื™ื<small>ื›ืชื•ื‘ืช ื“ื•ืืดืœ ืื—ืช ื‘ื›ืœ ืฉื•ืจื”. ืชืœืžื™ื“ ื™ื›ื•ืœ ืœื”ืฉืชื™ื™ืš ืœื™ื•ืชืจ ืžื›ื™ืชื” ืื—ืช.</small><textarea name="emails" dir="ltr" spellcheck="false" placeholder="student@example.com">${esc((row?.emails||[]).join("\n"))}</textarea></label>
410
  <label>ืžื•ืจื™ ื”ื›ื™ืชื”<small>ื›ืชื•ื‘ืช ื“ื•ืืดืœ ืื—ืช ื‘ื›ืœ ืฉื•ืจื”. ืœืžื•ืจื™ื ืชื”ื™ื” ื’ื™ืฉื” ืœื›ื™ืชื” ื”ื–ื•.</small><textarea name="teachers" dir="ltr" spellcheck="false" placeholder="teacher@example.com">${esc((row?.teachers||[]).join("\n"))}</textarea></label>
411
  <div class="mx-class-form-actions"><button class="mx-btn solid" type="submit">${creating?"ืคืชื™ื—ืช ื”ื›ื™ืชื”":"ืฉืžื™ืจืช ื”ืฉื™ื ื•ื™ื™ื"}</button><button class="mx-btn" type="button" data-class-action="cancel">ื‘ื™ื˜ื•ืœ</button><span role="status" aria-live="polite"></span></div></form>`;
tests/test_answer_browser.py CHANGED
@@ -109,7 +109,7 @@ def test_two_device_capture_retake_failure_retry_and_original_attempt(scan_serve
109
  phone.locator('[data-submit]').click()
110
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืขืœื™ื ืืช ื”ืชืฉื•ื‘ื”โ€ฆ', timeout=10000)
111
  assert started.is_set()
112
- computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="m_rect_area"]').click()
113
  release.set()
114
  playwright.expect(phone.locator('[data-status]')).to_contain_text('ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื”', timeout=10000)
115
  assert computer.locator('.mx-answer-image').count() == 0
@@ -187,9 +187,10 @@ def test_success_response_loss_is_safe_to_retry(scan_server):
187
  playwright.expect(phone.locator('[data-status]')).to_contain_text('ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื”')
188
  with store.connect() as c:
189
  assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1
190
- computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="m_rect_area"]').click()
191
  computer.locator('.mx-choices button[data-m=""]').click()
192
- assert computer.evaluate("!!JSON.parse(localStorage.getItem('mathly.progress.v1:'+'%s@test.invalid')).done.m_rect_area" % OWNER)
 
193
  finally:
194
  browser.close()
195
 
 
109
  phone.locator('[data-submit]').click()
110
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืขืœื™ื ืืช ื”ืชืฉื•ื‘ื”โ€ฆ', timeout=10000)
111
  assert started.is_set()
112
+ computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="m_def"]').click()
113
  release.set()
114
  playwright.expect(phone.locator('[data-status]')).to_contain_text('ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื”', timeout=10000)
115
  assert computer.locator('.mx-answer-image').count() == 0
 
187
  playwright.expect(phone.locator('[data-status]')).to_contain_text('ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื”')
188
  with store.connect() as c:
189
  assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1
190
+ computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="m_def"]').click()
191
  computer.locator('.mx-choices button[data-m=""]').click()
192
+ # one right answer of ten is recorded, but does not finish the skill
193
+ assert computer.evaluate("JSON.parse(localStorage.getItem('mathly.progress.v1:'+'%s@test.invalid')).solved.m_def.length===1" % OWNER)
194
  finally:
195
  browser.close()
196
 
tests/test_answer_scans.py CHANGED
@@ -158,6 +158,7 @@ def test_scan_api_end_to_end_failure_retry_and_restart(answer_store, monkeypatch
158
  import copy
159
  from app.math_skills import algebra9, question_version
160
  graph = copy.deepcopy(algebra9())
 
161
  graph['skills'][0]['sample'] = {'type': 'open-ended', 'question': 'Draw a proof', 'version': 1}
162
  monkeypatch.setattr('app.answer_routes.algebra9', lambda: graph)
163
  monkeypatch.delenv('ANTHROPIC_API_KEY', raising=False)
 
158
  import copy
159
  from app.math_skills import algebra9, question_version
160
  graph = copy.deepcopy(algebra9())
161
+ graph['skills'][0].pop('questions')
162
  graph['skills'][0]['sample'] = {'type': 'open-ended', 'question': 'Draw a proof', 'version': 1}
163
  monkeypatch.setattr('app.answer_routes.algebra9', lambda: graph)
164
  monkeypatch.delenv('ANTHROPIC_API_KEY', raising=False)
tests/test_math_skills.py CHANGED
@@ -9,6 +9,7 @@ from app.math_skills import algebra9, practice_graph, problems, question_version
9
 
10
  def test_open_ended_samples_and_content_identity():
11
  graph = copy.deepcopy(algebra9())
 
12
  sample = graph["skills"][0]["sample"] = {"type": "open-ended", "version": 1,
13
  "question": "Draw a triangle."}
14
  assert problems(graph) == []
@@ -26,22 +27,37 @@ def test_open_ended_samples_and_content_identity():
26
 
27
  def test_choice_samples_still_require_one_correct_choice():
28
  graph = copy.deepcopy(algebra9())
29
- graph["skills"][0]["sample"]["choices"] = []
30
- assert any("exactly one correct choice" in item for item in problems(graph))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
 
33
  def test_the_algebra_graph_is_whole():
34
  graph = algebra9()
35
  assert problems(graph) == []
36
- assert len(graph["skills"]) == 140
37
- assert {unit["id"] for unit in graph["units"]} == set("tkqearzhsmnpwd")
38
  assert {unit["subject"] for unit in graph["units"]} == {"ืžืชืžื˜ื™ืงื”"}
39
 
40
 
41
  def test_a_broken_graph_is_named():
42
  graph = copy.deepcopy(algebra9())
43
  skill = lambda sid: next(item for item in graph["skills"] if item["id"] == sid)
44
- skill("t_ext")["prereqs"] = ["t_square"]
45
  assert problems(graph)[0].startswith("cycle:")
46
 
47
 
@@ -58,7 +74,7 @@ def test_math_is_the_only_page_and_api():
58
  class FakeSolver:
59
  def solve(self, image, media_type):
60
  return {"readable": True, "problem": "$xยฒ = 9$", "steps": [{"hint": "h", "work": "w"}],
61
- "answer": "$x = ยฑ3$", "skill": "e_simple", "note": ""}
62
 
63
 
64
  def test_a_photographed_problem_is_solved(monkeypatch):
@@ -83,3 +99,17 @@ def test_rollout_and_rollback_question_variants():
83
  assert opened['question_version']==question_version(opened['sample'])
84
  assert len(fallback['sample']['choices']) == 3
85
  assert all('fallback_sample' not in s for s in enabled['skills'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  def test_open_ended_samples_and_content_identity():
11
  graph = copy.deepcopy(algebra9())
12
+ graph["skills"][0].pop("questions")
13
  sample = graph["skills"][0]["sample"] = {"type": "open-ended", "version": 1,
14
  "question": "Draw a triangle."}
15
  assert problems(graph) == []
 
27
 
28
  def test_choice_samples_still_require_one_correct_choice():
29
  graph = copy.deepcopy(algebra9())
30
+ graph["skills"][0]["questions"][3]["choices"] = []
31
+ assert "t_ext question 4: needs exactly one correct choice" in problems(graph)
32
+
33
+
34
+ def test_question_lists():
35
+ graph = copy.deepcopy(algebra9())
36
+ skill = lambda sid: next(item for item in graph["skills"] if item["id"] == sid)
37
+ listed = [s for s in graph["skills"] if "questions" in s]
38
+ assert [s["id"] for s in graph["skills"] if "sample" in s] == ["a_30_side"] # the photographed answer
39
+ assert len(listed) == 95 and all(len(s["questions"]) == 10 for s in listed)
40
+ skill("t_ext")["sample"] = skill("t_ext")["questions"][0]
41
+ skill("t_gcf")["questions"] = []
42
+ skill("q_props")["questions"][0] = {"type": "open-ended", "version": 1, "question": "Sketch it."}
43
+ found = problems(graph)
44
+ assert "t_ext: needs a sample or a list of questions, not both" in found
45
+ assert "t_gcf: the question list is empty" in found
46
+ assert "q_props question 1: a question list holds multiple-choice questions only" in found
47
 
48
 
49
  def test_the_algebra_graph_is_whole():
50
  graph = algebra9()
51
  assert problems(graph) == []
52
+ assert len(graph["skills"]) == 96
53
+ assert {unit["id"] for unit in graph["units"]} == set("tkiqalzevgrmhsnbcopwd")
54
  assert {unit["subject"] for unit in graph["units"]} == {"ืžืชืžื˜ื™ืงื”"}
55
 
56
 
57
  def test_a_broken_graph_is_named():
58
  graph = copy.deepcopy(algebra9())
59
  skill = lambda sid: next(item for item in graph["skills"] if item["id"] == sid)
60
+ skill("t_ext")["prereqs"] = ["t_formulas"]
61
  assert problems(graph)[0].startswith("cycle:")
62
 
63
 
 
74
  class FakeSolver:
75
  def solve(self, image, media_type):
76
  return {"readable": True, "problem": "$xยฒ = 9$", "steps": [{"hint": "h", "work": "w"}],
77
+ "answer": "$x = ยฑ3$", "skill": "e_way", "note": ""}
78
 
79
 
80
  def test_a_photographed_problem_is_solved(monkeypatch):
 
99
  assert opened['question_version']==question_version(opened['sample'])
100
  assert len(fallback['sample']['choices']) == 3
101
  assert all('fallback_sample' not in s for s in enabled['skills'])
102
+
103
+
104
+ def test_each_subunit_names_its_units_subject():
105
+ graph = practice_graph(False)
106
+ subject = {unit["id"]: unit["subject"] for unit in graph["units"]}
107
+ assert all(skill["subject"] == subject[skill["unit"]] for skill in graph["skills"])
108
+
109
+
110
+ def test_a_class_of_a_split_unit_keeps_its_content():
111
+ from app.main import units_of
112
+ assert units_of({"lessons": ["k"]}) == ["k", "i"] # ื“ืœืชื•ืŸ ื•ืžืฉื•ืœืฉ ืฉื•ื•ื” ืฉื•ืงื™ื™ื
113
+ assert units_of({"lessons": ["m", "t"]}) == ["t", "m", "b", "c"] # ืžืœื‘ืŸ, ืžืขื•ื™ืŸ ื•ืจื™ื‘ื•ืข
114
+ assert units_of({"lessons": ["i"]}) == ["i"]
115
+ assert units_of({}) == [unit["id"] for unit in algebra9()["units"]]