3v324v23 commited on
Commit
e79873b
ยท
1 Parent(s): ef4cd18

feat: add misconception patterns tracking and UI enhancements

Browse files

- Introduced a new log for tracking misconceptions across multiple-choice answers, capturing details such as skill, question, and activity type.
- Implemented a "patterns that come back" card on the math home screen to display recurring misconceptions, enhancing student feedback.
- Developed a patterns sheet that shows detailed information about misconceptions, including tips and practice links.
- Updated the UI to reflect these changes, ensuring a consistent user experience.
- Added tests to validate the new functionality and ensure existing features remain unaffected.
- Modified database schema to accommodate new scan states for answer submissions.

app/answer_routes.py CHANGED
@@ -12,7 +12,7 @@ from starlette.concurrency import run_in_threadpool
12
  from .answer_scans import AnswerScans, MAX_IMAGE, ScanError, clean_image, public_result, uuid_text
13
  from .answer_grading import AnswerGrader
14
  from .math_photo import PhotoUnavailable
15
- from .math_skills import algebra9, question_version
16
 
17
  log = logging.getLogger("mathly.answer_routes")
18
  VERDICTS = {"correct": "ื ื›ื•ื ื”", "partial": "ื ื›ื•ื ื” ื—ืœืงื™ืช", "incorrect": "ืฉื’ื•ื™ื”",
@@ -88,6 +88,22 @@ def install_answer_routes(app, signed_in):
88
  return reply(call(user, "resume", question_id=question["id"], question_version=version,
89
  snapshot={"name": question["name"], **sample}, new=body.get("new") is True))
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  @app.get("/api/math/answer-attempts/{attempt_id}")
92
  def get_attempt(attempt_id: str, user=Depends(student)):
93
  return reply(call(user, "get", attempt_id=uuid_text(attempt_id)))
@@ -157,6 +173,11 @@ def install_answer_routes(app, signed_in):
157
  def confirm(scan_id: str, user=Depends(student)):
158
  return reply(usable(call(user, "confirm", scan_id=uuid_text(scan_id))))
159
 
 
 
 
 
 
160
  @app.post("/api/math/answer-scans/{scan_id}/uploads")
161
  def begin(scan_id: str, body: dict, user=Depends(student)):
162
  result = usable(call(user, "begin", scan_id=uuid_text(scan_id), key=uuid_text(body.get("key"))))
@@ -172,7 +193,9 @@ def install_answer_routes(app, signed_in):
172
  data = await photo.read(MAX_IMAGE + 1)
173
  result = await run_in_threadpool(submit, user, sid, lease, data, photo.content_type)
174
  note(user, "answer_upload", result)
175
- if result.get("grading_available") and not result.get("grading"):
 
 
176
  background_tasks.add_task(automatic_grade, user, result["attempt"]["id"])
177
  return reply(result)
178
 
 
12
  from .answer_scans import AnswerScans, MAX_IMAGE, ScanError, clean_image, public_result, uuid_text
13
  from .answer_grading import AnswerGrader
14
  from .math_photo import PhotoUnavailable
15
+ from .math_skills import algebra9, question_version, quiz_snapshot
16
 
17
  log = logging.getLogger("mathly.answer_routes")
18
  VERDICTS = {"correct": "ื ื›ื•ื ื”", "partial": "ื ื›ื•ื ื” ื—ืœืงื™ืช", "incorrect": "ืฉื’ื•ื™ื”",
 
88
  return reply(call(user, "resume", question_id=question["id"], question_version=version,
89
  snapshot={"name": question["name"], **sample}, new=body.get("new") is True))
90
 
91
+ @app.post("/api/math/quiz-attempts")
92
+ def quiz_attempt(body: dict, user=Depends(student)):
93
+ """An answer to a question of a unit's summarizing quiz: `new` starts the attempt of a new sitting."""
94
+ if open_ended_locked():
95
+ raise ScanError(403, "open_ended_locked")
96
+ unit = next((u for u in algebra9()["units"] if u["id"] == body.get("unit") and "quiz" in u), None)
97
+ index = body.get("index")
98
+ if not unit or type(index) is not int or not 0 <= index < len(unit["quiz"]["questions"]):
99
+ raise ScanError(404, "not_found")
100
+ snapshot = quiz_snapshot(unit, index)
101
+ version = question_version(snapshot)
102
+ if body.get("question_version") != version:
103
+ raise ScanError(409, "question_changed")
104
+ return reply(call(user, "resume", question_id=f"quiz:{unit['id']}:{index}", question_version=version,
105
+ snapshot=snapshot, new=body.get("new") is True))
106
+
107
  @app.get("/api/math/answer-attempts/{attempt_id}")
108
  def get_attempt(attempt_id: str, user=Depends(student)):
109
  return reply(call(user, "get", attempt_id=uuid_text(attempt_id)))
 
173
  def confirm(scan_id: str, user=Depends(student)):
174
  return reply(usable(call(user, "confirm", scan_id=uuid_text(scan_id))))
175
 
176
+ @app.post("/api/math/answer-scans/{scan_id}/decline")
177
+ def decline(scan_id: str, user=Depends(student)):
178
+ """The phone refuses the request, or the computer withdraws it."""
179
+ return reply(call(user, "decline", scan_id=uuid_text(scan_id)))
180
+
181
  @app.post("/api/math/answer-scans/{scan_id}/uploads")
182
  def begin(scan_id: str, body: dict, user=Depends(student)):
183
  result = usable(call(user, "begin", scan_id=uuid_text(scan_id), key=uuid_text(body.get("key"))))
 
193
  data = await photo.read(MAX_IMAGE + 1)
194
  result = await run_in_threadpool(submit, user, sid, lease, data, photo.content_type)
195
  note(user, "answer_upload", result)
196
+ # a quiz answer is graded only once the quiz is handed in (the page asks for it then)
197
+ quiz = str(result.get("attempt", {}).get("question_id", "")).startswith("quiz:")
198
+ if result.get("grading_available") and not result.get("grading") and not quiz:
199
  background_tasks.add_task(automatic_grade, user, result["attempt"]["id"])
200
  return reply(result)
201
 
app/answer_scans.py CHANGED
@@ -130,6 +130,7 @@ def public_result(result):
130
  if result.get("scan"):
131
  for key in ("idempotency_key", "lease_id", "lease_until"):
132
  result["scan"].pop(key, None)
 
133
  if result.get("attachment"):
134
  result["attachment"].pop("object_key", None)
135
  if result.get("grading"):
@@ -138,6 +139,14 @@ def public_result(result):
138
  return result
139
 
140
 
 
 
 
 
 
 
 
 
141
  def uuid_text(value):
142
  try:
143
  return str(uuid.UUID(str(value)))
 
130
  if result.get("scan"):
131
  for key in ("idempotency_key", "lease_id", "lease_until"):
132
  result["scan"].pop(key, None)
133
+ result["scan"]["code"] = scan_code(result["scan"].get("id"))
134
  if result.get("attachment"):
135
  result["attachment"].pop("object_key", None)
136
  if result.get("grading"):
 
139
  return result
140
 
141
 
142
+ def scan_code(scan_id):
143
+ """The 4 digits shown on both the computer and the phone, so the student can match the request."""
144
+ try:
145
+ return f"{uuid.UUID(str(scan_id)).int % 10000:04d}"
146
+ except (ValueError, TypeError, AttributeError):
147
+ return ""
148
+
149
+
150
  def uuid_text(value):
151
  try:
152
  return str(uuid.UUID(str(value)))
app/math_algebra9.json CHANGED
@@ -9,7 +9,28 @@
9
  "subject": "ืžืชืžื˜ื™ืงื”",
10
  "name": "ื˜ื›ื ื™ืงื” ืืœื’ื‘ืจื™ืช 1",
11
  "source": "ืคืจื™ืกืช ื˜ืณ ืชืฉืคืดื–",
12
- "when": "ืกืคื˜ืžื‘ืจ"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  },
14
  {
15
  "id": "t2",
@@ -19,14 +40,60 @@
19
  "when": "ืื•ืงื˜ื•ื‘ืจ",
20
  "from": [
21
  "t"
22
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  },
24
  {
25
  "id": "k",
26
  "subject": "ืžืชืžื˜ื™ืงื”",
27
  "name": "ื“ืœืชื•ืŸ",
28
  "source": "ืคืจื™ืกืช ื˜ืณ ืชืฉืคืดื–",
29
- "when": "ืกืคื˜ืžื‘ืจ"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  },
31
  {
32
  "id": "i",
@@ -36,7 +103,32 @@
36
  "when": "ืื•ืงื˜ื•ื‘ืจ",
37
  "from": [
38
  "k"
39
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  },
41
  {
42
  "id": "q",
 
9
  "subject": "ืžืชืžื˜ื™ืงื”",
10
  "name": "ื˜ื›ื ื™ืงื” ืืœื’ื‘ืจื™ืช 1",
11
  "source": "ืคืจื™ืกืช ื˜ืณ ืชืฉืคืดื–",
12
+ "when": "ืกืคื˜ืžื‘ืจ",
13
+ "quiz": {
14
+ "minutes": 45,
15
+ "questions": [
16
+ {
17
+ "points": 25,
18
+ "question": "ืคืชื—ื• ืกื•ื’ืจื™ื™ื ื•ืคืฉื˜ื•. ื”ืจืื• ืืช ื“ืจืš ื”ืคืชืจื•ืŸ.\nื. $(x + 4)(x โˆ’ 3)$\nื‘. $(2x โˆ’ 5)ยฒ$\nื’. $(3x + 2)(3x โˆ’ 2) โˆ’ 9xยฒ$"
19
+ },
20
+ {
21
+ "points": 25,
22
+ "question": "ืคืชืจื• ืืช ื”ืžืฉื•ื•ืื” ื•ื‘ื“ืงื• ืืช ื”ืคืชืจื•ืŸ ืฉืžืฆืืชื: $(x + 3)(x โˆ’ 2) = (x โˆ’ 1)(x + 4) + 4$"
23
+ },
24
+ {
25
+ "points": 25,
26
+ "question": "ืื•ืจืš ืžืœื‘ืŸ ื’ื“ื•ืœ ื‘ึพ$5$ ืกืดืž ืžืฆืœืข ืฉืœ ืจื™ื‘ื•ืข, ื•ืจื•ื—ื‘ื• ืงื˜ืŸ ื‘ึพ$3$ ืกืดืž ืžืฆืœืข ื”ืจื™ื‘ื•ืข. ื ืกืžืŸ ืืช ืฆืœืข ื”ืจื™ื‘ื•ืข ื‘ึพ$x$.\nื. ื‘ื˜ืื• ื‘ืขื–ืจืช $x$ ืืช ืฉื˜ื— ื”ืžืœื‘ืŸ ื•ืคืฉื˜ื•.\nื‘. ืฉื˜ื— ื”ืžืœื‘ืŸ ื’ื“ื•ืœ ื‘ึพ$1$ ืกืžืดืจ ืžืฉื˜ื— ื”ืจื™ื‘ื•ืข. ืžืฆืื• ืืช ืฆืœืข ื”ืจื™ื‘ื•ืข ื•ืืช ืžื™ื“ื•ืช ื”ืžืœื‘ืŸ."
27
+ },
28
+ {
29
+ "points": 25,
30
+ "question": "ื. ื—ืฉื‘ื• ื‘ืœื™ ืžื—ืฉื‘ื•ืŸ, ื‘ืขื–ืจืช ื ื•ืกื—ืื•ืช ื”ื›ืคืœ ื”ืžืงื•ืฆืจ, ื•ื”ืจืื• ื›ื™ืฆื“: $49ยฒ$ ื•ึพ$102 ยท 98$.\nื‘. ื”ื•ื›ื™ื—ื• ืฉืœื›ืœ $x$ ืžืชืงื™ื™ื: $(x + 3)ยฒ โˆ’ (x โˆ’ 3)ยฒ = 12x$."
31
+ }
32
+ ]
33
+ }
34
  },
35
  {
36
  "id": "t2",
 
40
  "when": "ืื•ืงื˜ื•ื‘ืจ",
41
  "from": [
42
  "t"
43
+ ],
44
+ "quiz": {
45
+ "minutes": 45,
46
+ "questions": [
47
+ {
48
+ "points": 30,
49
+ "question": "ืคืจืงื• ืœื’ื•ืจืžื™ื:\nื. $6xยฒ โˆ’ 9x$\nื‘. $xยฒ โˆ’ 7x + 12$\nื’. $4xยฒ โˆ’ 25$"
50
+ },
51
+ {
52
+ "points": 25,
53
+ "question": "ืคืชืจื• ื‘ืขื–ืจืช ืคื™ืจื•ืง ืœื’ื•ืจืžื™ื:\nื. $xยฒ โˆ’ 5x = 0$\nื‘. $xยฒ + 2x โˆ’ 15 = 0$"
54
+ },
55
+ {
56
+ "points": 20,
57
+ "question": "ืžืฆืื• ืืช ืชื—ื•ื ื”ื”ืฆื‘ื” ื•ืฆืžืฆืžื•: $(xยฒ โˆ’ 16) / (xยฒ โˆ’ 4x)$"
58
+ },
59
+ {
60
+ "points": 25,
61
+ "question": "ืžืฆืื• ืืช ืชื—ื•ื ื”ื”ืฆื‘ื”, ื—ืฉื‘ื• ื•ืคืฉื˜ื•: $(xยฒ โˆ’ 4) / (3x) ยท (6x) / (x + 2)$"
62
+ }
63
+ ]
64
+ }
65
  },
66
  {
67
  "id": "k",
68
  "subject": "ืžืชืžื˜ื™ืงื”",
69
  "name": "ื“ืœืชื•ืŸ",
70
  "source": "ืคืจื™ืกืช ื˜ืณ ืชืฉืคืดื–",
71
+ "when": "ืกืคื˜ืžื‘ืจ",
72
+ "quiz": {
73
+ "minutes": 45,
74
+ "questions": [
75
+ {
76
+ "points": 20,
77
+ "question": "ื‘ื“ืœืชื•ืŸ $ABCD$: $AB = AD$ ื•ึพ$CB = CD$. ื ืชื•ืŸ: $โˆขBAD = 70ยฐ$ ื•ึพ$โˆขBCD = 50ยฐ$. ื—ืฉื‘ื• ืืช $โˆขABC$ ื•ื ืžืงื•.",
78
+ "figure": "A=0,3.71; B=-2.6,0; C=0,-5.58; D=2.6,0; poly A B C D; tick A B 1; tick A D 1; tick C B 2; tick C D 2; ang B A D 70ยฐ; ang D C B 50ยฐ"
79
+ },
80
+ {
81
+ "points": 30,
82
+ "question": "ื‘ื“ืœืชื•ืŸ $ABCD$: $AB = AD$ ื•ึพ$CB = CD$, ื•ึพ$AC$ ื”ื•ื ื”ืืœื›ืกื•ืŸ ื”ืจืืฉื™.\nื. ื”ื•ื›ื™ื—ื•: $โ–ณABC โ‰… โ–ณADC$.\nื‘. ื”ื•ื›ื™ื—ื• ืฉึพ$AC$ ื—ื•ืฆื” ืืช $โˆขBAD$ ื•ืืช $โˆขBCD$.",
83
+ "figure": "A=0,3.71; B=-2.6,0; C=0,-5.58; D=2.6,0; poly A B C D; tick A B 1; tick A D 1; tick C B 2; tick C D 2; line A C"
84
+ },
85
+ {
86
+ "points": 25,
87
+ "question": "ื‘ื“ืœืชื•ืŸ $ABCD$ ($AB = AD$, $CB = CD$) ื”ืืœื›ืกื•ื ื™ื ื ื—ืชื›ื™ื ื‘ื ืงื•ื“ื” $E$. ื ืชื•ืŸ: $AB = 10$ ืกืดืž, $BD = 12$ ืกืดืž, $CE = 15$ ืกืดืž.\nื. ืžื”ื• ืื•ืจืš $BE$? ื ืžืงื• ืœืคื™ ืชื›ื•ื ื•ืช ื”ืืœื›ืกื•ื ื™ื ื‘ื“ืœืชื•ืŸ.\nื‘. ื—ืฉื‘ื• ืืช $AE$.\nื’. ื—ืฉื‘ื• ืืช ืฉื˜ื— ื”ื“ืœืชื•ืŸ.",
88
+ "figure": "A=0,8; B=-6,0; C=0,-15; D=6,0; E=0,0@ne; poly A B C D; line A C; line B D; right A E D; len A B 10; len E C 15; tick A B 1; tick A D 1; tick C B 2; tick C D 2"
89
+ },
90
+ {
91
+ "points": 25,
92
+ "question": "ื‘ืžืขืจื›ืช ืฆื™ืจื™ื ื ืชื•ื ื•ืช ื”ื ืงื•ื“ื•ืช $A(0, 6)$, $B(3, 2)$, $C(0, โˆ’4)$, $D(โˆ’3, 2)$.\nื. ื—ืฉื‘ื• ืืช ืื•ืจื›ื™ ื”ืฆืœืขื•ืช ื•ื”ืจืื• ืฉึพ$ABCD$ ื”ื•ื ื“ืœืชื•ืŸ.\nื‘. ื—ืฉื‘ื• ืืช ืฉื˜ื— ื”ื“ืœืชื•ืŸ.",
93
+ "figure": "grid; A=0,6@ne; B=3,2@e; C=0,-4@se; D=-3,2@w; poly A B C D"
94
+ }
95
+ ]
96
+ }
97
  },
98
  {
99
  "id": "i",
 
103
  "when": "ืื•ืงื˜ื•ื‘ืจ",
104
  "from": [
105
  "k"
106
+ ],
107
+ "quiz": {
108
+ "minutes": 45,
109
+ "questions": [
110
+ {
111
+ "points": 25,
112
+ "question": "ื‘ืžืฉื•ืœืฉ $ABC$, ื”ืงื˜ืข $AD$ ื”ื•ื ื’ื ื’ื•ื‘ื” ืœืฆืœืข $BC$ ื•ื’ื ืชื™ื›ื•ืŸ ืœืฆืœืข $BC$. ื”ื•ื›ื™ื—ื• ืฉึพ$โ–ณABC$ ืฉื•ื•ื” ืฉื•ืงื™ื™ื.",
113
+ "figure": "A=0,4; B=-3,0; C=3,0; D=0,0@s; poly A B C; line A D; right A D C; tick B D 1; tick D C 1"
114
+ },
115
+ {
116
+ "points": 25,
117
+ "question": "ื‘ืžืฉื•ืœืฉ $ABC$: $AB = AC$ ื•ึพ$โˆขA = 36ยฐ$. ื”ืงื˜ืข $BD$ ื—ื•ืฆื” ืืช $โˆขB$ (ื”ื ืงื•ื“ื” $D$ ืขืœ $AC$).\nื. ื—ืฉื‘ื• ืืช ื–ื•ื•ื™ื•ืช ื”ืžืฉื•ืœืฉ $BDC$.\nื‘. ื”ื•ื›ื™ื—ื• ืฉื”ืžืฉื•ืœืฉื™ื $ABD$ ื•ึพ$BDC$ ืฉื•ื•ื™ ืฉื•ืงื™ื™ื.",
118
+ "figure": "A=0,3.08; B=-1,0; C=1,0; D=0.62,1.18@e; poly A B C; line B D; tick A B 1; tick A C 1; ang B A C 36ยฐ; ang A B D; ang D B C"
119
+ },
120
+ {
121
+ "points": 25,
122
+ "question": "ื‘ืžืฉื•ืœืฉ $ABC$, ื”ืงื˜ืข $BD$ ื—ื•ืฆื” ืืช $โˆขABC$ (ื”ื ืงื•ื“ื” $D$ ืขืœ $AC$). ื“ืจืš $D$ ื”ืขื‘ื™ืจื• ื™ืฉืจ ื”ืžืงื‘ื™ืœ ืœึพ$BC$, ื•ื”๏ฟฝ๏ฟฝื ื—ื•ืชืš ืืช $AB$ ื‘ื ืงื•ื“ื” $E$. ื”ื•ื›ื™ื—ื• ืฉึพ$โ–ณEBD$ ืฉื•ื•ื” ืฉื•ืงื™ื™ื.",
123
+ "figure": "A=-1,4; B=-3,0; C=3,0; D=0.71,2.29@e; E=-1.85,2.29@w; poly A B C; line B D; line E D; par E D 1; par B C 1; ang E B D; ang D B C"
124
+ },
125
+ {
126
+ "points": 25,
127
+ "question": "ื‘ืžืขืจื›ืช ืฆื™ืจื™ื ื ืชื•ื ื•ืช ื”ื ืงื•ื“ื•ืช $A(โˆ’3, 0)$, $B(3, 0)$, $C(0, 5)$.\nื. ื”ืจืื• ืฉึพ$โ–ณABC$ ืฉื•ื•ื” ืฉื•ืงื™ื™ื.\nื‘. $M$ ื”ื™ื ืืžืฆืข $AB$. ืžืฆืื• ืืช ืฉื™ืขื•ืจื™ $M$ ื•ื”ืจืื• ืฉึพ$CM โŠฅ AB$.\nื’. ื—ืฉื‘ื• ืืช ืฉื˜ื— ื”ืžืฉื•ืœืฉ.",
128
+ "figure": "grid; A=-3,0@sw; B=3,0@se; C=0,5@ne; poly A B C"
129
+ }
130
+ ]
131
+ }
132
  },
133
  {
134
  "id": "q",
app/math_skills.py CHANGED
@@ -9,6 +9,9 @@ the one it came from (`from`) so classes that chose it keep their content. `mont
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
  """
13
  from __future__ import annotations
14
 
@@ -107,6 +110,39 @@ def sample_problems(where: str, sid: str, sample: dict, listed: bool, mistakes:
107
  return out
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  def problems(graph: dict) -> list[str]:
111
  """What is wrong with a graph: unknown units, skills or mistakes, a cycle, a link implied by another,
112
  a question without exactly one right answer, or a mistake that sends the student to a skill that is not before it."""
@@ -114,6 +150,7 @@ def problems(graph: dict) -> list[str]:
114
  skills = {s["id"]: s for s in graph["skills"]}
115
  mistakes = graph["mistakes"]
116
  out = [f"{u['id']}: subject is required" for u in graph["units"] if not str(u.get("subject") or "").strip()]
 
117
  out += [f"{s['id']}: unknown unit {s['unit']}" for s in skills.values() if s["unit"] not in units]
118
  out += [f"{s['id']}: unknown prerequisite {p}" for s in skills.values() for p in s["prereqs"] if p not in skills]
119
  if out:
@@ -156,6 +193,10 @@ def algebra9() -> dict:
156
  return graph
157
 
158
 
 
 
 
 
159
  def practice_graph(answer_scans_enabled: bool, open_ended_locked: bool = False) -> dict:
160
  """Keep practice usable during a scan rollout or rollback.
161
 
@@ -165,6 +206,11 @@ def practice_graph(answer_scans_enabled: bool, open_ended_locked: bool = False)
165
  """
166
  graph = copy.deepcopy(algebra9())
167
  subject = {unit["id"]: unit["subject"] for unit in graph["units"]}
 
 
 
 
 
168
  for skill in graph["skills"]:
169
  skill["subject"] = subject[skill["unit"]] # a subunit belongs to its unit's subject
170
  fallback = skill.pop("fallback_sample", None)
 
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
+
13
+ A unit may end with a summarizing quiz (`quiz`): open-ended questions answered on paper and photographed, a time limit
14
+ in minutes, and points per question that add up to 100. Its questions are graded only after the quiz is handed in.
15
  """
16
  from __future__ import annotations
17
 
 
110
  return out
111
 
112
 
113
+ def quiz_problems(unit: dict) -> list[str]:
114
+ """A unit's summarizing quiz: a time limit, and open-ended questions whose points add up to 100."""
115
+ quiz, where = unit["quiz"], f"{unit['id']} quiz"
116
+ if not isinstance(quiz, dict):
117
+ return [f"{where}: must be an object"]
118
+ out = []
119
+ if type(quiz.get("minutes")) is not int or not 5 <= quiz["minutes"] <= 120:
120
+ out.append(f"{where}: minutes must be a whole number from 5 to 120")
121
+ qs = quiz.get("questions")
122
+ if not isinstance(qs, list) or not qs:
123
+ return out + [f"{where}: needs questions"]
124
+ for n, q in enumerate(qs, 1):
125
+ at = f"{where} question {n}"
126
+ if not isinstance(q, dict) or not isinstance(q.get("question"), str) or not q["question"].strip():
127
+ out.append(f"{at}: question text is required")
128
+ continue
129
+ if type(q.get("points")) is not int or q["points"] <= 0:
130
+ out.append(f"{at}: points must be a positive whole number")
131
+ if "choices" in q:
132
+ out.append(f"{at}: a quiz question is open-ended")
133
+ if "figure" in q:
134
+ out += figure_problems(at, q["figure"])
135
+ if not out and sum(q["points"] for q in qs) != 100:
136
+ out.append(f"{where}: points add up to {sum(q['points'] for q in qs)}, not 100")
137
+ return out
138
+
139
+
140
+ def quiz_snapshot(unit: dict, index: int) -> dict:
141
+ """What an answer to quiz question `index` is graded against, and what its version is computed from."""
142
+ q = unit["quiz"]["questions"][index]
143
+ return {"name": f"ื‘ื•ื—ืŸ ืžืกื›ื: {unit['name']} ยท ืฉืืœื” {index + 1}", **q, "type": "open-ended"}
144
+
145
+
146
  def problems(graph: dict) -> list[str]:
147
  """What is wrong with a graph: unknown units, skills or mistakes, a cycle, a link implied by another,
148
  a question without exactly one right answer, or a mistake that sends the student to a skill that is not before it."""
 
150
  skills = {s["id"]: s for s in graph["skills"]}
151
  mistakes = graph["mistakes"]
152
  out = [f"{u['id']}: subject is required" for u in graph["units"] if not str(u.get("subject") or "").strip()]
153
+ out += [p for u in graph["units"] if "quiz" in u for p in quiz_problems(u)]
154
  out += [f"{s['id']}: unknown unit {s['unit']}" for s in skills.values() if s["unit"] not in units]
155
  out += [f"{s['id']}: unknown prerequisite {p}" for s in skills.values() for p in s["prereqs"] if p not in skills]
156
  if out:
 
193
  return graph
194
 
195
 
196
+ def algebra9_unit(unit_id: str) -> dict:
197
+ return next(u for u in algebra9()["units"] if u["id"] == unit_id)
198
+
199
+
200
  def practice_graph(answer_scans_enabled: bool, open_ended_locked: bool = False) -> dict:
201
  """Keep practice usable during a scan rollout or rollback.
202
 
 
206
  """
207
  graph = copy.deepcopy(algebra9())
208
  subject = {unit["id"]: unit["subject"] for unit in graph["units"]}
209
+ for unit in graph["units"]:
210
+ if "quiz" in unit: # photographed and graded: only with answer storage and open-ended grading on
211
+ unit["quiz"]["locked"] = not answer_scans_enabled or open_ended_locked
212
+ for i, q in enumerate(unit["quiz"]["questions"]):
213
+ q["question_version"] = question_version(quiz_snapshot(algebra9_unit(unit["id"]), i))
214
  for skill in graph["skills"]:
215
  skill["subject"] = subject[skill["unit"]] # a subunit belongs to its unit's subject
216
  fallback = skill.pop("fallback_sample", None)
openspec/changes/add-misconception-patterns/.openspec.yaml ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ schema: spec-driven
2
+ created: 2026-11-03
openspec/changes/add-misconception-patterns/design.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Design
2
+
3
+ ## Context
4
+
5
+ Progress lives in one object `P` in localStorage (`mathly.progress.v1:<email>`), saved by `save()` in `static/math.js`. The scoring rules live in `static/math-sky.js` (`window.MathlySky`), whose `ensure(P)` fills missing fields so older saved progress keeps loading. Wrong answers reach three places, each of which already knows the choice's mistake tag (`data-m` on the button, or `choice.mistake`):
6
+
7
+ - `answer()`: skill practice. Calls `Sky.wrong(P,id)` and shows `G.mistakes[m]`.
8
+ - `reviewAnswer()`: mixed review. Calls `Sky.reviewed` and `Sky.wrong`.
9
+ - `quizSubmit()`: practice exam. Already builds a per-exam mistake tally, but doesn't keep it.
10
+
11
+ `G.mistakes[tag]` has `name`, `tip` and `back` (a skill id or `null`). The home screen is assembled in the path view as `topBar() + daily() + reviewCard() + examSoon() + quizCard() + โ€ฆ`.
12
+
13
+ ## Goals / Non-Goals
14
+
15
+ **Goals:**
16
+ - One place that writes a log entry, called from the three answer paths.
17
+ - Pure summary logic in `math-sky.js`, testable through the page without UI.
18
+ - The data shape is ready for a later personal path to read.
19
+
20
+ **Non-Goals:**
21
+ - Syncing the log to the server or a teacher view.
22
+ - Changing what any activity shows or scores today.
23
+ - Open-ended or photographed answers (they have no tags).
24
+
25
+ ## Decisions
26
+
27
+ **Log shape: an array `P.slips` of `{at, id, q, m, via}`.** `at` is an ISO time, `id` the skill, `q` the question index, `m` the tag, and `via` is `"practice" | "review" | "exam"`. The newest are kept last and the array is capped at 500. A flat append-only log is chosen over pre-aggregated counters per tag because the 30/14/60-day windows and the later personal path need the time of each event. 500 short entries are about 40 KB, well inside localStorage. `ensure()` creates `slips` as an array, as it already does for `comets`.
28
+
29
+ **`Sky.slip(P, id, q, m, via, now)` writes; `Sky.patterns(P, mistakes, now)` reads.** `patterns` returns `{recurring:[โ€ฆ], stopped:[โ€ฆ]}`. Each item is `{m, n, skills:[ids by count], last, practise}`. `practise` is `mistakes[m].back`, or else the skill where `m` came up most, with ties going to the most recent. Ranking: number of distinct skills, then count, then most recent. Both lists are cut to 3. For "stopped", a sliding pass over the tag's sorted times checks whether any two entries are 30 days or less apart. Keeping this in `math-sky.js` next to `due`/`streak` follows the existing split: rules there, screens in `math.js`.
30
+
31
+ **Practice exam logs on hand-in, in `quizSubmit`**, from the final `res` list, not when picks are made, because picks can change and a left exam is discarded. All entries share the hand-in time.
32
+
33
+ **Questions are logged by index (`q`).** This matches `P.solved`/`P.missed`. If question lists change, an old index may point at a different question. The summary only uses `m` and `id`, so that doesn't matter.
34
+
35
+ **UI: `patternsCard()` after `reviewCard()` on the home screen, and a `showPatterns()` sheet opened by `data-act="patterns"`.** These reuse the existing sheet markup (`mx-sheet`, `mx-shtop`, `mx-x`) and buttons with `data-id` that already open a skill's practice. Copy, in Hebrew and plural like the rest of the app:
36
+ - Card title: "ื“ืคื•ืกื™ื ืฉื—ื•ื–ืจื™ื". Body: "โ€Ž<name> ื”ื•ืคื™ืขื” ื‘ึพN ืžื™ื•ืžื ื•ื™ื•ืช ืœืื—ืจื•ื ื”. ื›ื“ืื™ ืœื—ื–ืง." With only stopped patterns: "โ€Ž<name> ื›ื‘ืจ ืœื ื—ื•ื–ืจืช. ื›ืœ ื”ื›ื‘ื•ื“ ืขืœ ื”ืชื™ืงื•ืŸ."
37
+ - Each sheet entry: name, tip, "N ืคืขืžื™ื ื‘ึพK ืžื™ื•ืžื ื•ื™ื•ืช ยท ืœืื—ืจื•ื ื” <relative day>", and a "ืœื—ื–ืง: <short>" button.
38
+ - The stopped section is headed "ื“ืคื•ืกื™ื ืฉื ืขืœืžื•".
39
+
40
+ The card sits after review so that today's due review stays first: retrieval comes before targeted work.
41
+
42
+ ## Risks / Trade-offs
43
+
44
+ - [A guess or a slip is counted as a misconception] โ†’ It needs two occurrences within 30 days before it shows, and only the top three are shown.
45
+ - [Showing mistakes may discourage some students] โ†’ The words are about the mistake, the card links straight to an action, and patterns that stopped are shown as a win.
46
+ - [The log exists on one device only] โ†’ Accepted for now, as with all progress; moving it to the server is a later change.
47
+ - [Tag names reused across topics with different meanings] โ†’ Tags are global in `math_algebra9.json` by design. Only the skill list reveals the spread.
48
+
49
+ ## Migration Plan
50
+
51
+ No migration is needed. `ensure()` adds an empty `slips` to old progress. Rolling back leaves an unused `slips` array in storage, which is harmless.
openspec/changes/add-misconception-patterns/proposal.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Proposal
2
+
3
+ ## Why
4
+
5
+ Every wrong choice in the math path is already tagged with a named misconception (244 of them, e.g. `sign_mid`, `ext_partial`), but the tag is used only for the immediate tip and then forgotten: a wrong answer records nothing but a reset run. So the app cannot see that a student keeps making the same mistake across different skills, which is the weakness that actually blocks understanding. Remembering these tags is also the foundation for a later personal path that mixes weak and strong skills.
6
+
7
+ ## What Changes
8
+
9
+ - Record every wrong answer's misconception, with its skill, question, date and where it happened (skill practice, mixed review, practice exam), in the student's progress on the device.
10
+ - Add a "patterns that come back" (ื“ืคื•ืกื™ื ืฉื—ื•ื–ืจื™ื) card on the math home screen when a misconception has come up at least twice recently, naming the misconception and in how many skills it appears.
11
+ - Opening the card shows up to three recurring misconceptions, each with its tip, how often and how recently it came up, and buttons to practise the skill to strengthen.
12
+ - The patterns also show where the student already looks: the skill to strengthen is marked "ืœื—ื–ืง" on the path, and a mistake that is now a pattern says so in its feedback, with the skill to strengthen.
13
+ - A pattern that has not come up again for a while leaves the list, and the card says when a pattern used to recur and has stopped ("ื ืขืœื"), so improvement is visible.
14
+ - Wording stays about the mistake, never about the student, matching the existing quiet-mistake tone.
15
+ - Unit-quiz (photographed, open-ended) answers have no misconception tags and are not included.
16
+ - Nothing is sent to the server; progress stays in localStorage as today. Existing progress without the new log keeps working.
17
+
18
+ ## Capabilities
19
+
20
+ ### New Capabilities
21
+
22
+ - `misconception-patterns`: recording a student's tagged mistakes across all multiple-choice practice and showing them the misconceptions that recur across skills, with what to practise.
23
+
24
+ ### Modified Capabilities
25
+
26
+ (none: there are no main specs yet; skill practice, review and the practice exam keep their current behaviour and only also record the mistake)
27
+
28
+ ## Impact
29
+
30
+ - `static/math-sky.js`: progress shape gains a mistake log, and functions to add to it and to summarise it.
31
+ - `static/math.js`: wrong answers in practice (`answer`), review (`reviewAnswer`) and the practice exam (`quizSubmit`) log the mistake; new home card and sheet.
32
+ - `static/math.css`: styles for the card and sheet.
33
+ - `tests/test_sky_browser.py`: browser tests for logging and the card.
34
+ - No server, API or data-file changes.
openspec/changes/add-misconception-patterns/specs/misconception-patterns/spec.md ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Spec Delta
2
+
3
+ ## Purpose
4
+
5
+ Remembers which named misconceptions a student's wrong answers show, across all multiple-choice practice, and shows the student the misconceptions that keep coming back across skills, with what to practise, so they work on the cause and not only on one skill.
6
+
7
+ ## ADDED Requirements
8
+
9
+ ### Requirement: Every tagged wrong answer is recorded
10
+ The system SHALL record each wrong multiple-choice answer whose choice names a misconception, with the misconception, the skill, the question, the time and the activity (skill practice, mixed review or practice exam). Practice-exam answers SHALL be recorded once, when the exam is handed in. Recording SHALL NOT change the feedback, scoring, mastery or review of the answer.
11
+
12
+ #### Scenario: A wrong answer in skill practice is recorded
13
+ - **WHEN** a student picks a wrong choice tagged `sign_mid` in skill `t_ext`
14
+ - **THEN** the student's progress holds one record with misconception `sign_mid`, skill `t_ext`, that question, the time and activity "practice"
15
+
16
+ #### Scenario: A wrong answer in mixed review is recorded
17
+ - **WHEN** a student picks a wrong choice in a mixed review
18
+ - **THEN** a record with activity "review" is added, and the skill's next review is scheduled exactly as before
19
+
20
+ #### Scenario: A practice exam is recorded when handed in
21
+ - **WHEN** a student changes a wrong pick before handing in a practice exam, then hands it in with two wrong answers
22
+ - **THEN** exactly two records with activity "exam" are added, for the final picks only
23
+
24
+ #### Scenario: A left practice exam records nothing
25
+ - **WHEN** a student leaves a practice exam without handing it in
26
+ - **THEN** no records are added
27
+
28
+ #### Scenario: A right answer records nothing
29
+ - **WHEN** a student picks the right choice
30
+ - **THEN** no record is added
31
+
32
+ ### Requirement: The mistake log stays on the device and is bounded
33
+ The mistake log SHALL be kept with the rest of the student's progress on the device, per account, and SHALL keep only the newest 500 records. Progress saved before this change SHALL load and work with an empty log.
34
+
35
+ #### Scenario: Old progress without a log
36
+ - **WHEN** a student whose saved progress has no mistake log opens the math path
37
+ - **THEN** the path works as before and the first wrong answer starts the log
38
+
39
+ #### Scenario: The log is capped
40
+ - **WHEN** a 501st record is added
41
+ - **THEN** the oldest record is dropped and 500 remain
42
+
43
+ ### Requirement: Recurring misconceptions are identified
44
+ A misconception SHALL count as recurring when it was recorded at least twice in the last 30 days and at least once in the last 14 days. Recurring misconceptions SHALL be ordered by how many different skills they appeared in, then by count, then by most recent, and at most three SHALL be shown.
45
+
46
+ #### Scenario: Two occurrences make a pattern
47
+ - **WHEN** `sign_mid` was recorded twice in the last 30 days, once of them in the last 14 days, and `ext_partial` once
48
+ - **THEN** `sign_mid` is a recurring misconception and `ext_partial` is not
49
+
50
+ #### Scenario: Spread across skills comes first
51
+ - **WHEN** `sign_mid` was recorded 3 times in 3 different skills and `move_sign` 4 times in 1 skill
52
+ - **THEN** `sign_mid` is listed before `move_sign`
53
+
54
+ #### Scenario: Old records do not count
55
+ - **WHEN** a misconception's only records are older than 30 days
56
+ - **THEN** it is not a recurring misconception
57
+
58
+ ### Requirement: The home screen shows a patterns card
59
+ The math home screen SHALL show a "patterns that come back" card when there is at least one recurring misconception, naming the top one and in how many skills it appeared. Without recurring or stopped patterns the card SHALL NOT be shown. The card's words SHALL describe the mistake, not the student.
60
+
61
+ #### Scenario: Card appears
62
+ - **WHEN** a student has a recurring misconception
63
+ - **THEN** the home screen shows the card with that misconception's name and its number of skills
64
+
65
+ #### Scenario: No card without a pattern
66
+ - **WHEN** a student has no recurring misconception and no pattern that has stopped
67
+ - **THEN** no patterns card is shown
68
+
69
+ ### Requirement: The patterns sheet says what to practise
70
+ Opening the card SHALL show each recurring misconception (at most three) with its name, its tip, how many times and in how many skills it came up in the last 30 days, and when it last came up. Each SHALL offer a button to practise the skill to strengthen: the misconception's own back-skill when it names one, otherwise the skill where it came up most. Each button SHALL open that skill's practice.
71
+
72
+ #### Scenario: A misconception with a back-skill
73
+ - **WHEN** the recurring misconception `ext_partial` names `t_ext` as its back-skill
74
+ - **THEN** its entry offers a button that opens practice of `t_ext`
75
+
76
+ #### Scenario: A misconception without a back-skill
77
+ - **WHEN** the recurring misconception `sign_mid` has no back-skill and came up most in `t_simplify`
78
+ - **THEN** its entry offers a button that opens practice of `t_simplify`
79
+
80
+ ### Requirement: Improvement is shown
81
+ The patterns sheet SHALL list, separately and at most three, misconceptions that were recorded at least twice within some 30-day span, last recorded between 14 and 60 days ago, as patterns that have stopped. When no misconception is recurring but one has stopped, the card SHALL be shown and say so.
82
+
83
+ #### Scenario: A pattern stops
84
+ - **WHEN** `sign_mid` was recorded 3 times 20 days ago and not since
85
+ - **THEN** the sheet lists `sign_mid` among the patterns that have stopped, and not among the recurring ones
86
+
87
+ #### Scenario: A long-stopped pattern is no longer listed
88
+ - **WHEN** a misconception was last recorded more than 60 days ago
89
+ - **THEN** it is listed neither as recurring nor as stopped
90
+
91
+ ### Requirement: The path marks the skills to strengthen
92
+ On the math path, every skill that a recurring misconception names as its skill to practise SHALL be marked as a skill to strengthen ("ืœื—ื–ืง") on its star, so a student following the path meets it there. The "continue here" mark of the next skill SHALL take precedence over this tag.
93
+
94
+ #### Scenario: A finished skill to strengthen is marked
95
+ - **WHEN** `ext_partial` is recurring, names `t_ext` as its back-skill, and `t_ext` is finished
96
+ - **THEN** the star of `t_ext` on the path is marked and tagged "ืœื—ื–ืง"
97
+
98
+ #### Scenario: No pattern, no mark
99
+ - **WHEN** the student has no recurring misconception
100
+ - **THEN** no star on the path is marked to strengthen
101
+
102
+ ### Requirement: A recurring mistake is named where it happens
103
+ When a wrong answer in skill practice or mixed review shows a misconception that is recurring (counting this answer), its feedback SHALL add a short note saying how many times and in how many skills it came up in the last 30 days, with a button to practise its skill to strengthen unless that skill is already offered in the same feedback.
104
+
105
+ #### Scenario: The second time the same mistake comes up
106
+ - **WHEN** `ext_partial` was recorded once in `t_ext_eq` and the student now picks an `ext_partial` choice in `t_simplify`
107
+ - **THEN** the feedback says it came up 2 times in 2 skills and offers practice of `t_ext` once
108
+
109
+ #### Scenario: A first mistake has no note
110
+ - **WHEN** a misconception is recorded for the first time
111
+ - **THEN** the feedback shows no pattern note
openspec/changes/add-misconception-patterns/tasks.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tasks
2
+
3
+ ## 1. Mistake log and summary rules (static/math-sky.js)
4
+
5
+ - [x] 1.1 Make `ensure(P)` create `P.slips` as an array when missing or invalid; verify with a browser test that progress saved without `slips` loads the path without errors
6
+ - [x] 1.2 Add `Sky.slip(P,id,q,m,via,now)` that appends `{at,id,q,m,via}` and keeps the newest 500; export it on `window.MathlySky`; verify with a page-evaluated test that 501 calls leave 500 entries with the oldest dropped
7
+ - [x] 1.3 Add `Sky.patterns(P,mistakes,now)` returning `{recurring,stopped}` per the spec (2+ in 30 days with 1 in the last 14 โ†’ recurring; 2 within some 30-day span, last seen 14โ€“60 days ago โ†’ stopped; ranked by skills, count, recency; 3 each; `practise` = back-skill or most frequent skill); verify with page-evaluated tests covering each spec scenario under "Recurring misconceptions are identified" and "Improvement is shown"
8
+
9
+ ## 2. Logging wrong answers (static/math.js)
10
+
11
+ - [x] 2.1 In `answer()` call `Sky.slip(...,"practice")` on a wrong tagged choice before `save()`; verify a browser test picks a wrong choice in `t_ext` and finds one entry with that tag and `via:"practice"`, and that the existing quiet-mistake test still passes
12
+ - [x] 2.2 In `reviewAnswer()` log wrong answers with `"review"`; verify the existing review test still passes and a wrong review answer adds one `review` entry
13
+ - [x] 2.3 In `quizSubmit()` log each final wrong pick with `"exam"`; verify a test that changes a pick and hands in logs only final picks, and that quitting an exam logs nothing
14
+
15
+ ## 3. Patterns card and sheet (static/math.js, static/math.css)
16
+
17
+ - [x] 3.1 Add `patternsCard()` after `reviewCard()` in the home screen, shown only when `Sky.patterns` has a recurring or stopped pattern, with the copy from design.md; verify browser tests with seeded progress: no card when empty, card naming the top misconception and its skill count, card wording for stopped-only
18
+ - [x] 3.2 Add the `patterns` sheet (`data-act="patterns"`): up to three recurring entries with name, tip, count, skill count, last-seen day and a "ืœื—ื–ืง" button opening the practise skill, then a "ื“ืคื•ืกื™ื ืฉื ืขืœืžื•" section; verify a browser test that the button for a tag with `back` opens that skill and for a tag without `back` opens its most frequent skill
19
+ - [x] 3.3 Style the card and sheet to match `mx-review`/`mx-quiz-card` in light and dark, at phone width; verify by screenshot at 375px and that no horizontal scroll appears
20
+ - [x] 3.4 Update the header comment of `static/math.js` (progress fields) to describe `slips`; verify by reading the comment
21
+
22
+ ## 4. Patterns where the student looks (path and feedback)
23
+
24
+ - [x] 4.1 Mark the skills to strengthen on the path (gold ring and "ืœื—ื–ืง" tag, "ืžืžืฉื™ื›ื™ื ื›ืืŸ" wins); verify `test_the_path_marks_the_skill_to_strengthen` and a 375px screenshot
25
+ - [x] 4.2 Add the pattern note to practice and review feedback, without repeating a button already offered; verify `test_a_mistake_that_is_a_pattern_says_so_where_it_happens` and `test_a_first_mistake_has_no_pattern_note`
26
+
27
+ ## 5. Integration check
28
+
29
+ - [x] 5.1 Run `pytest tests/test_sky_browser.py tests/test_math_skills.py` and confirm all pass, including the existing practice, review and exam tests unchanged
static/answer-scans.js CHANGED
@@ -3,11 +3,12 @@
3
  (()=>{
4
  const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
5
  const fmt=s=>esc(s).replace(/\$([^$]+)\$/g,'<span class="m">$1</span>');
6
- const states={waiting:"ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ",confirmed:"ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ",uploading:"ืžืขืœื™ื ืืช ื”ืชืฉื•ื‘ื”โ€ฆ",received:"ื”ืชืฉื•ื‘ื” ื”ืชืงื‘ืœื”",failed:"ื”ื”ืขืœืื” ื ื›ืฉืœื”",expired:"ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืคื’ื”",superseded:"ื”ื‘ืงืฉื” ื”ื•ื—ืœืคื”"};
7
  const errors={expired:"ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืคื’ื”. ืฆืจื• ื‘ืงืฉื” ื—ื“ืฉื” ื•ื ืกื• ืฉื•ื‘.",superseded:"ื”ื‘ืงืฉื” ื”ื•ื—ืœืคื”. ื‘ื—ืจื• ืืช ื”ื‘ืงืฉื” ื”ืขื“ื›ื ื™ืช.",upload_in_progress:"ื”ืขืœืื” ื›ื‘ืจ ืžืชื‘ืฆืขืช. ืžืžืชื™ื ื™ื ืœืขื“ื›ื•ืŸโ€ฆ",confirmation_required:"ื™ืฉ ืœืืฉืจ ืืช ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืชื—ื™ืœื”.",invalid_upload:"ื”ื”ืขืœืื” ื”ื•ืคืกืงื”. ื ืกื• ืฉื•ื‘.",question_changed:"ื”ืฉืืœื” ื”ืฉืชื ืชื”. ืจืขื ื ื• ืืช ื”ืขืžื•ื“ ื•ืคืชื—ื• ืื•ืชื” ืžื—ื“ืฉ.",invalid_image:"ืœื ื ื™ืชืŸ ืœืงืจื•ื ืืช ื”ืชืžื•ื ื”. ืฆืœืžื• ืฉื•ื‘.",image_too_large:"ื”ืชืžื•ื ื” ื’ื“ื•ืœื” ืžื“ื™. ืฆืœืžื• ืฉื•ื‘.",unsupported_image:"ื‘ื—ืจื• ืชืžื•ื ืช JPEG, PNG ืื• WebP.",answer_storage_unavailable:"ืฉืžื™ืจืช ืชืฉื•ื‘ื•ืช ืื™ื ื” ื–ืžื™ื ื” ื›ืจื’ืข. ื ืกื• ืฉื•ื‘ ืžืื•ื—ืจ ื™ื•ืชืจ.",sign_in_required:"ื™ืฉ ืœื”ื™ื›ื ืก ืœืื•ืชื• ื—ืฉื‘ื•ืŸ ื‘ืฉื ื™ ื”ืžื›ืฉื™ืจื™ื."};
8
  Object.assign(errors,{grading_unavailable:"ื‘ื“ื™ืงืช ืชืฉื•ื‘ื•ืช ืื™ื ื” ื–ืžื™ื ื” ื›ืจื’ืข ื‘ืืชืจ.",grading_failed:"ื”ื‘ื“ื™ืงื” ื ื›ืฉืœื”. ื”ืชืžื•ื ื” ื ืฉืžืจื”; ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘.",grading_interrupted:"ื”ื‘ื“ื™ืงื” ื”ื•ืคืกืงื”. ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘.",grading_limit:"ื”ื’ืขืชื ืœืžื›ืกืช 20 ื”ื‘ื“ื™ืงื•ืช ื”ื™ื•ืžื™ืช. ื ืกื• ืฉื•ื‘ ืžื—ืจ.",grading_busy:"ื›ื‘ืจ ืžืชื‘ืฆืขื•ืช ื‘ื“ื™ืงื•ืช ื‘ื—ืฉื‘ื•ืŸ. ื ืกื• ืฉื•ื‘ ื‘ืขื•ื“ ืจื’ืข.",answer_required:"ื™ืฉ ืœืฆืœื ื•ืœืฉืœื•ื— ืชืฉื•ื‘ื” ืœืคื ื™ ื”ื‘ื“ื™ืงื”."});
9
  const verdicts={correct:"ื ื›ื•ืŸ! ื”ืคืชืจื•ืŸ ืžืœื",partial:"ืคืชืจื•ืŸ ื ื›ื•ืŸ ื‘ื—ืœืงื•",incorrect:"ื™ืฉ ืœืชืงืŸ ืืช ื”ืคืชืจื•ืŸ",unreadable:"ืœื ื ื™ืชืŸ ืœืงืจื•ื ืืช ื”ืชืฉื•ื‘ื”",needs_review:"ื ื“ืจืฉืช ื”ื‘ื”ืจื” ืœืคื ื™ ืžืชืŸ ืฆื™ื•ืŸ"};
10
- let api=null,account=null,epoch=0,question=null,inbox=null,inboxTimer=null,inboxPoll=null,capture=null,visibleRoot=null,onGraded=null;
 
11
  const live=(e)=>e===epoch&&!!account;
12
  async function json(url,options={}){
13
  const r=await api(url,{cache:"no-store",...options});
@@ -23,35 +24,35 @@ function closeCapture(){
23
  if(capture){if(capture.url)URL.revokeObjectURL(capture.url);capture.node.remove();capture.returnFocus?.focus?.();capture=null;}
24
  }
25
  function reset(){
26
- epoch++;if(question)question.node.replaceChildren();closeQuestion();closeCapture();clearTimeout(inboxTimer);inboxTimer=null;
27
- inbox?.remove();inbox=null;account=null;onGraded=null;
28
  }
29
  function configure(options){
30
  reset();api=options.api;account=options.enabled?options.account:null;visibleRoot=options.root;onGraded=options.onGraded;
31
  if(!account)return;
32
- // Foreground discovery is available in the responsive phone app/PWA.
33
- if(matchMedia("(max-width: 700px)").matches||navigator.maxTouchPoints){
34
- inbox=document.createElement("section");inbox.className="mx-scan-inbox mx-wrap";
35
- inbox.setAttribute("aria-label","ื‘ืงืฉื•ืช ืœืฆื™ืœื•ื ืชืฉื•ื‘ื”");visibleRoot.prepend(inbox);pollInbox(epoch);
36
- }
37
  }
 
 
38
  async function pollInbox(e){
39
  clearTimeout(inboxTimer);if(!live(e)||!inbox||inboxPoll===e)return;
40
  if(document.hidden){inboxTimer=setTimeout(()=>pollInbox(e),5000);return;}
41
  inboxPoll=e;
42
- let delay=5000;
43
  try{
44
  const list=await json("/api/math/answer-scans");if(!live(e)||!inbox)return;
45
- const signature=JSON.stringify(list.map(r=>[r.scan.id,r.scan.state]));
46
- if(inbox.dataset.signature!==signature){
47
- inbox.dataset.signature=signature;
48
- inbox.innerHTML=list.length?`<h2>ื‘ืงืฉื•ืช ืœืฆื™ืœื•ื ืชืฉื•ื‘ื”</h2>${list.map((r,i)=>`<button class="mx-btn" data-scan-index="${i}"><b>${fmt(r.attempt.snapshot.question)}</b><small>ื ื™ืกื™ื•ืŸ ${esc(r.attempt.id.slice(0,8))} ยท ${esc(states[r.scan.state])}</small></button>`).join("")}`:"";
49
- inbox.querySelectorAll("[data-scan-index]").forEach(button=>button.onclick=()=>openCapture(list[Number(button.dataset.scanIndex)]));
50
  }
 
 
51
  }catch(error){
52
  if(!live(e))return;
53
  if(error.status===401){reset();return;}
54
- if(inbox){inbox.textContent="ืœื ื ื™ืชืŸ ืœื‘ื“ื•ืง ื‘ืงืฉื•ืช ืฆื™ืœื•ื ื›ืจื’ืข. ืžื ืกื™ื ืฉื•ื‘โ€ฆ";delete inbox.dataset.signature;}delay=15000;
55
  }finally{if(inboxPoll===e)inboxPoll=null;}
56
  if(live(e))inboxTimer=setTimeout(()=>pollInbox(e),delay);
57
  }
@@ -86,13 +87,19 @@ async function renderQuestion(q){
86
  q.signature=signature;
87
  q.node.innerHTML=`<small>ื ื™ืกื™ื•ืŸ ${esc(r.attempt.id.slice(0,8))}</small><p role="status" aria-live="polite">${esc(statusText)}</p>
88
  ${received?`<img class="mx-answer-image" alt="ื”ืชืฉื•ื‘ื” ื”ืžืฆื•ืœืžืช ืฉืœื›ื" hidden>${gradingPanel(r,checking)}
89
- <button class="mx-btn" data-new-attempt ${checking?"disabled":""}>ื ื™ืกื™ื•ืŸ ื—ื“ืฉ ื•ืฆื™ืœื•ื ืžื—ื“ืฉ</button>`:`<button class="mx-btn solid" data-request>${s?["expired","superseded"].includes(state)?"ื™ืฆื™ืจืช ื‘ืงืฉืช ืฆื™ืœื•ื ื—ื“ืฉื”":state==="failed"?"ื ื™ืกื™ื•ืŸ ื ื•ืกืฃ":"ืคืชื—ื• ืืช Mathly ื‘ื˜ืœืคื•ืŸ ื‘ืื•ืชื• ื—ืฉื‘ื•ืŸ":"ืกืจื™ืงืช ื”ืชืฉื•ื‘ื” ืฉืœื™ ื‘ื˜ืœืคื•ืŸ"}</button>`}`;
90
  if(!received){
91
- const b=q.node.querySelector("[data-request]");b.disabled=["uploading","waiting","confirmed"].includes(state);
92
  b.onclick=async()=>{
93
  b.disabled=true;
94
- try{const result=await post(`/api/math/answer-attempts/${r.attempt.id}/scans`,{question_id:q.id,renew:["expired","superseded","failed"].includes(state)});
95
- if(!current(q))return;q.result=result;q.signature=null;await renderQuestion(q);schedule(q);}
 
 
 
 
 
 
96
  catch(error){if(current(q)){q.node.querySelector("[role=status]").textContent=error.message;b.disabled=false;}}
97
  };
98
  }
@@ -151,44 +158,59 @@ async function checkAnswer(q,automatic=false){
151
  }
152
  }
153
  }
 
 
 
 
 
 
 
 
 
 
 
154
  function openCapture(result){
155
  closeCapture();const e=epoch;
156
  if(!live(e))return;
157
- const node=document.createElement("div");node.className="mx-scan-overlay";node.setAttribute("role","dialog");node.setAttribute("aria-modal","true");node.setAttribute("aria-labelledby","scanTitle");
158
- node.innerHTML=`<div class="mx-scan-card"><button class="mx-btn" data-close>ืกื’ื™ืจื”</button><h2 id="scanTitle">ืฆื™ืœื•ื ืชืฉื•ื‘ื”</h2>
159
- <p>${fmt(result.attempt.snapshot.question)}</p><small>ื ื™ืกื™ื•ืŸ ${esc(result.attempt.id.slice(0,8))}</small>
160
- <p data-status role="status" aria-live="polite">ื”ืื ืœืฆืœื ืชืฉื•ื‘ื” ืœืฉืืœื” ื”ื–ื•?</p>
161
- <button class="mx-btn solid" data-confirm>ื›ืŸ, ืื ื™ ืจื•ืฆื” ืœืฆืœื ืชืฉื•ื‘ื”</button>
162
- <button class="mx-btn" data-renew hidden>ื™ืฆื™ืจืช ื‘ืงืฉืช ืฆื™ืœื•ื ื—ื“ืฉื”</button>
163
- <input data-camera type="file" accept="image/*" capture="environment" hidden>
164
- <button class="mx-btn" data-take hidden>ืฆื™ืœื•ื ื”ืชืฉื•ื‘ื”</button>
165
  <img class="mx-answer-image" data-preview alt="ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืฉืœ ื”ืชืฉื•ื‘ื”" hidden>
166
- <p>ื›ืฉื”ื‘ื“ื™ืงื” ื”ืื•ื˜ื•ืžื˜ื™ืช ื–ืžื™ื ื”, ืฆื™ืœื•ื ื”ืชืฉื•ื‘ื” ื™ื™ืฉืœื— ืœื‘ื“ื™ืงื” ื‘ืืžืฆืขื•ืช AI. ื”ืžืฉื•ื‘ ื•ื”ืฆื™ื•ืŸ ื™ื•ืคื™ืขื• ื‘ืฉืืœื” ื‘ืžื—ืฉื‘.</p>
167
- <button class="mx-btn solid" data-submit hidden>ืฉืœื™ื—ืช ื”ืชืฉื•ื‘ื” ืœืžื—ืฉื‘</button></div>`;
168
- const c=capture={node,e,result,photo:null,url:null,key:null,busy:false,returnFocus:document.activeElement};visibleRoot.append(node);
169
- const status=node.querySelector("[data-status]"),camera=node.querySelector("[data-camera]"),take=node.querySelector("[data-take]"),send=node.querySelector("[data-submit]"),preview=node.querySelector("[data-preview]"),confirm=node.querySelector("[data-confirm]"),renew=node.querySelector("[data-renew]");
 
 
 
 
 
 
 
 
 
170
  const valid=()=>capture===c&&live(e),say=message=>{if(valid())status.textContent=message;};
171
- const controls=busy=>{c.busy=busy;take.disabled=send.disabled=confirm.disabled=renew.disabled=busy;};
172
- const expired=()=>{confirm.hidden=true;take.hidden=true;send.hidden=true;renew.hidden=false;say(errors.expired);};
173
- node.querySelector("[data-close]").onclick=closeCapture;
 
 
 
174
  node.onkeydown=event=>{
175
- if(event.key==="Escape"&&!c.busy){event.preventDefault();event.stopPropagation();closeCapture();}
176
- if(event.key==="Tab"){
177
- const buttons=[...node.querySelectorAll("button")].filter(b=>!b.hidden&&!b.disabled);
178
- if(event.shiftKey&&document.activeElement===buttons[0]){event.preventDefault();buttons.at(-1)?.focus();}
179
- else if(!event.shiftKey&&document.activeElement===buttons.at(-1)){event.preventDefault();buttons[0]?.focus();}
180
- }
181
  };
182
- confirm.onclick=async()=>{
183
- controls(true);
184
- try{const r=await post(`/api/math/answer-scans/${c.result.scan.id}/confirm`);if(!valid())return;c.result=r;
185
- if(r.attachment){success();return;}
186
- confirm.hidden=true;take.hidden=false;say("ื‘ื—ืจื• ืฆื™ืœื•ื. ืื ืชืชื‘ืงืฉื•, ืืคืฉืจื• ื’ื™ืฉื” ืœืžืฆืœืžื”.");take.focus();}
187
- catch(error){if(valid()){say(error.message);if(error.status===410)expired();}}
188
- finally{if(valid())controls(false);}
189
  };
190
- take.onclick=()=>{say("ืฆืœืžื• ืืช ื”ืคืชืจื•ืŸ. ืื ื”ืžืฆืœืžื” ื—ืกื•ืžื”, ืืคืฉืจื• ืื•ืชื” ื‘ื”ื’ื“ืจื•ืช ื”ืืชืจ ื•ื ืกื• ืฉื•ื‘.");camera.click();};
191
- camera.addEventListener("cancel",()=>say("ื”ืฆื™ืœื•ื ื‘ื•ื˜ืœ ืื• ืฉืœื ื ื™ืชื ื” ื”ืจืฉืื”. ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘ ื‘ื›ืคืชื•ืจ ื”ืฆื™ืœื•ื."));
192
  camera.onchange=async()=>{
193
  const file=camera.files[0];camera.value="";if(!file||!valid())return;
194
  controls(true);say("ืžื›ื™ื ื™ื ืืช ื”ืชืžื•ื ื”โ€ฆ");
@@ -197,15 +219,16 @@ function openCapture(result){
197
  if(photo.size>5000000)throw Error(errors.image_too_large);
198
  if(c.url)URL.revokeObjectURL(c.url);
199
  c.photo=photo;c.key=null;preview.src=c.url=URL.createObjectURL(photo);preview.hidden=false;
200
- take.textContent="ืฆื™ืœื•ื ืžื—ื“ืฉ";send.hidden=false;say("ื‘ื“ืงื• ืฉื›ืœ ื”ืคืชืจื•ืŸ ื‘ืจื•ืจ, ื•ืื– ืฉืœื—ื• ืืช ื”ืชืฉื•ื‘ื”.");
201
  }catch(error){say("ืœื ื ื™ืชืŸ ืœื”ื›ื™ืŸ ืืช ื”ืชืžื•ื ื”. ื ืกื• ืœืฆืœื ืฉื•ื‘.");}
202
  finally{if(valid())controls(false);}
203
  };
204
- const success=()=>{say("ื”ืชืฉื•ื‘ื” ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื” ื•ืžืฆื•ืจืคืช ืœืฉืืœื” ื‘ืžื—ืฉื‘.");take.hidden=send.hidden=confirm.hidden=renew.hidden=true;c.photo=null;if(valid())pollInbox(e);};
205
  send.onclick=async()=>{
206
  if(!c.photo||c.busy)return;
207
  controls(true);say(states.uploading);
208
  try{
 
209
  c.key=c.key||crypto.randomUUID();
210
  let b=await post(`/api/math/answer-scans/${c.result.scan.id}/uploads`,{key:c.key});if(!valid())return;
211
  if(b.attachment){success();return;}
@@ -216,17 +239,86 @@ function openCapture(result){
216
  if(!valid())return;say(error.message+" ื”ืชืžื•ื ื” ื ืฉืžืจื” ืœืชืฆื•ื’ื” ืžืงื“ื™ืžื”. ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘.");
217
  if(error.status===410||error.code==="superseded")expired();
218
  // Reuse the key after an uncertain response; the server returns prior success.
219
- }finally{if(valid())controls(false);}
 
 
 
 
 
 
220
  };
221
  renew.onclick=async()=>{
222
  controls(true);
223
  try{const r=await post(`/api/math/answer-attempts/${c.result.attempt.id}/scans`,{renew:true});if(!valid())return;
224
- c.result=r;c.key=null;renew.hidden=true;confirm.hidden=false;say("ื ื•ืฆืจื” ื‘ืงืฉื” ื—ื“ืฉื”. ื”ืื ืœืฆืœื ืชืฉื•ื‘ื” ืœืฉืืœื” ื”ื–ื•?");}
 
225
  catch(error){say(error.message);}
226
  finally{if(valid())controls(false);}
227
  };
228
  if(result.scan.state==="expired")expired();
229
- node.querySelector("[data-close]").focus();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  }
231
  document.addEventListener("visibilitychange",()=>{
232
  if(document.hidden)return;
@@ -234,5 +326,5 @@ document.addEventListener("visibilitychange",()=>{
234
  if(question?.result)pollQuestion(question);
235
  });
236
  window.addEventListener("online",()=>{if(account&&inbox)pollInbox(epoch);if(question?.result)pollQuestion(question);});
237
- window.MathlyAnswers={configure,mountQuestion,closeQuestion,reset};
238
  })();
 
3
  (()=>{
4
  const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
5
  const fmt=s=>esc(s).replace(/\$([^$]+)\$/g,'<span class="m">$1</span>');
6
+ const states={waiting:"ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ",confirmed:"ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ",uploading:"ืžืขืœื™ื ืืช ื”ืชืฉื•ื‘ื”โ€ฆ",received:"ื”ืชืฉื•ื‘ื” ื”ืชืงื‘ืœื”",failed:"ื”ื”ืขืœืื” ื ื›ืฉืœื”",expired:"ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืคื’ื”",superseded:"ื”ื‘ืงืฉื” ื”ื•ื—ืœืคื”",declined:"ื”ื‘ืงืฉื” ื ื“ื—ืชื” ื‘ื˜ืœืคื•ืŸ"};
7
  const errors={expired:"ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืคื’ื”. ืฆืจื• ื‘ืงืฉื” ื—ื“ืฉื” ื•ื ืกื• ืฉื•ื‘.",superseded:"ื”ื‘ืงืฉื” ื”ื•ื—ืœืคื”. ื‘ื—ืจื• ืืช ื”ื‘ืงืฉื” ื”ืขื“ื›ื ื™ืช.",upload_in_progress:"ื”ืขืœืื” ื›ื‘ืจ ืžืชื‘ืฆืขืช. ืžืžืชื™ื ื™ื ืœืขื“ื›ื•ืŸโ€ฆ",confirmation_required:"ื™ืฉ ืœืืฉืจ ืืช ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืชื—ื™ืœื”.",invalid_upload:"ื”ื”ืขืœืื” ื”ื•ืคืกืงื”. ื ืกื• ืฉื•ื‘.",question_changed:"ื”ืฉืืœื” ื”ืฉืชื ืชื”. ืจืขื ื ื• ืืช ื”ืขืžื•ื“ ื•ืคืชื—ื• ืื•ืชื” ืžื—ื“ืฉ.",invalid_image:"ืœื ื ื™ืชืŸ ืœืงืจื•ื ืืช ื”ืชืžื•ื ื”. ืฆืœืžื• ืฉื•ื‘.",image_too_large:"ื”ืชืžื•ื ื” ื’ื“ื•ืœื” ืžื“ื™. ืฆืœืžื• ืฉื•ื‘.",unsupported_image:"ื‘ื—ืจื• ืชืžื•ื ืช JPEG, PNG ืื• WebP.",answer_storage_unavailable:"ืฉืžื™ืจืช ืชืฉื•ื‘ื•ืช ืื™ื ื” ื–ืžื™ื ื” ื›ืจื’ืข. ื ืกื• ืฉื•ื‘ ืžืื•ื—ืจ ื™ื•ืชืจ.",sign_in_required:"ื™ืฉ ืœื”ื™ื›ื ืก ืœืื•ืชื• ื—ืฉื‘ื•ืŸ ื‘ืฉื ื™ ื”ืžื›ืฉื™ืจื™ื."};
8
  Object.assign(errors,{grading_unavailable:"ื‘ื“ื™ืงืช ืชืฉื•ื‘ื•ืช ืื™ื ื” ื–ืžื™ื ื” ื›ืจื’ืข ื‘ืืชืจ.",grading_failed:"ื”ื‘ื“ื™ืงื” ื ื›ืฉืœื”. ื”ืชืžื•ื ื” ื ืฉืžืจื”; ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘.",grading_interrupted:"ื”ื‘ื“ื™ืงื” ื”ื•ืคืกืงื”. ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘.",grading_limit:"ื”ื’ืขืชื ืœืžื›ืกืช 20 ื”ื‘ื“ื™ืงื•ืช ื”ื™ื•ืžื™ืช. ื ืกื• ืฉื•ื‘ ืžื—ืจ.",grading_busy:"ื›ื‘ืจ ืžืชื‘ืฆืขื•ืช ื‘ื“ื™ืงื•ืช ื‘ื—ืฉื‘ื•ืŸ. ื ืกื• ืฉื•ื‘ ื‘ืขื•ื“ ืจื’ืข.",answer_required:"ื™ืฉ ืœืฆืœื ื•ืœืฉืœื•ื— ืชืฉื•ื‘ื” ืœืคื ื™ ื”ื‘ื“ื™ืงื”."});
9
  const verdicts={correct:"ื ื›ื•ืŸ! ื”ืคืชืจื•ืŸ ืžืœื",partial:"ืคืชืจื•ืŸ ื ื›ื•ืŸ ื‘ื—ืœืงื•",incorrect:"ื™ืฉ ืœืชืงืŸ ืืช ื”ืคืชืจื•ืŸ",unreadable:"ืœื ื ื™ืชืŸ ืœืงืจื•ื ืืช ื”ืชืฉื•ื‘ื”",needs_review:"ื ื“ืจืฉืช ื”ื‘ื”ืจื” ืœืคื ื™ ืžืชืŸ ืฆื™ื•ืŸ"};
10
+ let api=null,account=null,epoch=0,question=null,inbox=false,inboxTimer=null,inboxPoll=null,capture=null,visibleRoot=null,onGraded=null,waiting=null;
11
+ const handled=new Set(); // requests this phone already answered (photographed or declined): not shown again
12
  const live=(e)=>e===epoch&&!!account;
13
  async function json(url,options={}){
14
  const r=await api(url,{cache:"no-store",...options});
 
24
  if(capture){if(capture.url)URL.revokeObjectURL(capture.url);capture.node.remove();capture.returnFocus?.focus?.();capture=null;}
25
  }
26
  function reset(){
27
+ epoch++;if(question)question.node.replaceChildren();closeQuestion();closeCapture();closeWait();clearTimeout(inboxTimer);inboxTimer=null;
28
+ inbox=false;account=null;onGraded=null;
29
  }
30
  function configure(options){
31
  reset();api=options.api;account=options.enabled?options.account:null;visibleRoot=options.root;onGraded=options.onGraded;
32
  if(!account)return;
33
+ // The phone (the responsive app/PWA) watches for requests from the computer and pops them up.
34
+ if(matchMedia("(max-width: 700px)").matches||navigator.maxTouchPoints){inbox=true;pollInbox(epoch);}
 
 
 
35
  }
36
+ /* The phone looks for requests every few seconds while the app is open. A new request pops up as a window with its
37
+ 4-digit code (the same code the computer shows); the student photographs the answer or declines. */
38
  async function pollInbox(e){
39
  clearTimeout(inboxTimer);if(!live(e)||!inbox||inboxPoll===e)return;
40
  if(document.hidden){inboxTimer=setTimeout(()=>pollInbox(e),5000);return;}
41
  inboxPoll=e;
42
+ let delay=3000;
43
  try{
44
  const list=await json("/api/math/answer-scans");if(!live(e)||!inbox)return;
45
+ if(capture&&!capture.ended){
46
+ const mine=list.find(r=>r.attempt.id===capture.result.attempt.id);
47
+ // withdrawn on the computer (or replaced there by a new request): this window has nothing left to answer
48
+ if(!mine||mine.scan.id!==capture.result.scan.id&&!capture.busy)capture.withdrawn?.(mine);
 
49
  }
50
+ const ask=list.find(r=>["waiting","confirmed","failed"].includes(r.scan.state)&&!handled.has(r.scan.id));
51
+ if(ask&&!capture)openCapture(ask);
52
  }catch(error){
53
  if(!live(e))return;
54
  if(error.status===401){reset();return;}
55
+ delay=15000;
56
  }finally{if(inboxPoll===e)inboxPoll=null;}
57
  if(live(e))inboxTimer=setTimeout(()=>pollInbox(e),delay);
58
  }
 
87
  q.signature=signature;
88
  q.node.innerHTML=`<small>ื ื™ืกื™ื•ืŸ ${esc(r.attempt.id.slice(0,8))}</small><p role="status" aria-live="polite">${esc(statusText)}</p>
89
  ${received?`<img class="mx-answer-image" alt="ื”ืชืฉื•ื‘ื” ื”ืžืฆื•ืœืžืช ืฉืœื›ื" hidden>${gradingPanel(r,checking)}
90
+ <button class="mx-btn" data-new-attempt ${checking?"disabled":""}>ื ื™ืกื™ื•ืŸ ื—ื“ืฉ ื•ืฆื™ืœื•ื ืžื—ื“ืฉ</button>`:`<button class="mx-btn solid mx-phone-cta" data-request><span aria-hidden="true">๐Ÿ“ฑ</span> ${s?["expired","superseded","declined"].includes(state)?"ื‘ืงืฉืช ืฆื™ืœื•ื ื—ื“ืฉื” ืœื˜ืœืคื•ืŸ":waitingState(state)?"ื”ืฆื’ืช ื”ืžืกืคืจ ืœื˜ืœืคื•ืŸ":"ื ื™ืกื™ื•ืŸ ื ื•ืกืฃ ื‘ื˜ืœืคื•ืŸ":"ืฆื™ืœื•ื ื”ืชืฉื•ื‘ื” ื‘ื˜ืœืคื•ืŸ"}</button>`}`;
91
  if(!received){
92
+ const b=q.node.querySelector("[data-request]");b.disabled=state==="uploading";
93
  b.onclick=async()=>{
94
  b.disabled=true;
95
+ try{
96
+ const result=waitingState(q.result.scan?.state)?q.result:await post(`/api/math/answer-attempts/${r.attempt.id}/scans`,
97
+ {question_id:q.id,renew:["expired","superseded","failed","declined"].includes(q.result.scan?.state)});
98
+ if(!current(q))return;q.result=result;q.signature=null;await renderQuestion(q);
99
+ const end=await waitForPhone(result,{question_id:q.id});
100
+ if(end&&current(q)){q.result=end;q.signature=null;await renderQuestion(q);}
101
+ schedule(q,0);
102
+ }
103
  catch(error){if(current(q)){q.node.querySelector("[role=status]").textContent=error.message;b.disabled=false;}}
104
  };
105
  }
 
158
  }
159
  }
160
  }
161
+ const waitingState=st=>st==="waiting"||st==="confirmed";
162
+ const codeHTML=code=>`<div class="mx-scan-code" dir="ltr" aria-label="ืžืกืคืจ ืžื–ื”ื” ${esc([...String(code||"")].join(" "))}">${[...String(code||"----")].map(d=>`<span>${esc(d)}</span>`).join("")}</div>`;
163
+ // keeps Tab inside a window
164
+ function trapTab(node,event){
165
+ if(event.key!=="Tab")return;
166
+ const buttons=[...node.querySelectorAll("button")].filter(b=>!b.hidden&&!b.disabled&&b.offsetParent);
167
+ if(event.shiftKey&&document.activeElement===buttons[0]){event.preventDefault();buttons.at(-1)?.focus();}
168
+ else if(!event.shiftKey&&document.activeElement===buttons.at(-1)){event.preventDefault();buttons[0]?.focus();}
169
+ }
170
+
171
+ /* ---------- the phone: a window for a request from the computer ---------- */
172
  function openCapture(result){
173
  closeCapture();const e=epoch;
174
  if(!live(e))return;
175
+ const node=document.createElement("div");node.className="mx-scan-overlay";node.setAttribute("role","alertdialog");node.setAttribute("aria-modal","true");node.setAttribute("aria-labelledby","scanTitle");
176
+ node.innerHTML=`<div class="mx-scan-card mx-scan-ask">
177
+ <h2 id="scanTitle"><span aria-hidden="true">๐Ÿ“ท</span> ื‘ืงืฉืช ืฆื™ืœื•ื ืžื”ืžื—ืฉื‘</h2>
178
+ <p>ื‘ื“ืงื• ืฉื–ื” ืื•ืชื• ืžืกืคืจ ืฉืžื•ืคื™ืข ื‘ืžื—ืฉื‘:</p>${codeHTML(result.scan.code)}
179
+ <div class="mx-scan-q"><small>ื”ืฉืืœื”</small><p>${fmt(result.attempt.snapshot.question)}</p></div>
 
 
 
180
  <img class="mx-answer-image" data-preview alt="ืชืฆื•ื’ื” ืžืงื“ื™ืžื” ืฉืœ ื”ืชืฉื•ื‘ื”" hidden>
181
+ <p data-status role="status" aria-live="polite">ื›ืชื‘ื• ืืช ื”ืคืชืจื•ืŸ ืขืœ ื“ืฃ ื•ืฆืœืžื• ืื•ืชื•.</p>
182
+ <input data-camera type="file" accept="image/*" capture="environment" hidden>
183
+ <div class="mx-scan-actions">
184
+ <button class="mx-btn solid" data-take>๐Ÿ“ท ืฆื™ืœื•ื ื”ืชืฉื•ื‘ื”</button>
185
+ <button class="mx-btn solid" data-submit hidden>ืฉืœื™ื—ื” ืœืžื—ืฉื‘</button>
186
+ <button class="mx-btn" data-renew hidden>ื‘ืงืฉื” ื—ื“ืฉื”</button>
187
+ <button class="mx-btn mx-btn-quiet" data-decline>ืกื™ืจื•ื‘</button>
188
+ <button class="mx-btn" data-close hidden>ืกื’ื™ืจื”</button>
189
+ </div>
190
+ <small>ื›ืฉื”ื‘ื“ื™ืงื” ื”ืื•ื˜ื•ืžื˜ื™ืช ื–ืžื™ื ื”, ืฆื™ืœื•ื ื”ืชืฉื•ื‘ื” ื™ื™ืฉืœื— ืœื‘ื“ื™ืงื” ื‘ืืžืฆืขื•ืช AI. ื”ืžืฉื•ื‘ ื™ื•ืคื™ืข ื‘ืฉืืœื” ื‘ืžื—ืฉื‘.</small></div>`;
191
+ const c=capture={node,e,result,photo:null,url:null,key:null,busy:false,ended:false,confirming:null,returnFocus:document.activeElement};visibleRoot.append(node);
192
+ const $=sel=>node.querySelector(sel);
193
+ const status=$("[data-status]"),camera=$("[data-camera]"),take=$("[data-take]"),send=$("[data-submit]"),preview=$("[data-preview]"),renew=$("[data-renew]"),decline=$("[data-decline]"),close=$("[data-close]");
194
  const valid=()=>capture===c&&live(e),say=message=>{if(valid())status.textContent=message;};
195
+ const controls=busy=>{c.busy=busy;take.disabled=send.disabled=renew.disabled=decline.disabled=busy;};
196
+ // the request is over: only closing is left
197
+ const finish=message=>{c.ended=true;handled.add(c.result.scan.id);take.hidden=send.hidden=renew.hidden=decline.hidden=true;close.hidden=false;say(message);close.focus();};
198
+ const expired=()=>{take.hidden=send.hidden=true;renew.hidden=false;say(errors.expired);};
199
+ c.withdrawn=mine=>{ if(c.photo&&mine&&waitingState(mine.scan.state)){c.result=mine;return;} finish("ื”ื‘ืงืฉื” ื‘ื•ื˜ืœื” ื‘ืžื—ืฉื‘."); };
200
+ close.onclick=()=>{closeCapture();if(valid())pollInbox(e);};
201
  node.onkeydown=event=>{
202
+ if(event.key==="Escape"){event.preventDefault();event.stopPropagation();if(c.ended)close.click();}
203
+ trapTab(node,event);
 
 
 
 
204
  };
205
+ // confirming happens in the background, so the camera opens right from the tap
206
+ const confirmed=()=>c.confirming||=(async()=>{
207
+ const r=await post(`/api/math/answer-scans/${c.result.scan.id}/confirm`);if(valid())c.result=r;return r;
208
+ })().catch(error=>{c.confirming=null;throw error;});
209
+ take.onclick=()=>{
210
+ say("ืฆืœืžื• ืืช ื”ืคืชืจื•ืŸ. ืื ื”ืžืฆืœืžื” ื—ืกื•ืžื”, ืืคืฉืจื• ืื•ืชื” ื‘ื”ื’ื“ืจื•ืช ื”ืืชืจ ื•ื ืกื• ืฉื•ื‘.");camera.click();
211
+ confirmed().then(r=>{if(r.attachment&&valid())success();}).catch(error=>{if(valid()){say(error.message);if(error.status===410)expired();}});
212
  };
213
+ camera.addEventListener("cancel",()=>say("ื”ืฆื™ืœื•ื ื‘ื•ื˜ืœ ืื• ืฉืœื ื ื™ืชื ื” ื”ืจืฉืื”. ืืคืฉืจ ืœืฆืœื ืฉื•ื‘ ืื• ืœืกืจื‘."));
 
214
  camera.onchange=async()=>{
215
  const file=camera.files[0];camera.value="";if(!file||!valid())return;
216
  controls(true);say("ืžื›ื™ื ื™ื ืืช ื”ืชืžื•ื ื”โ€ฆ");
 
219
  if(photo.size>5000000)throw Error(errors.image_too_large);
220
  if(c.url)URL.revokeObjectURL(c.url);
221
  c.photo=photo;c.key=null;preview.src=c.url=URL.createObjectURL(photo);preview.hidden=false;
222
+ take.textContent="ืฆื™ืœื•ื ืžื—ื“ืฉ";take.classList.remove("solid");send.hidden=false;say("ื‘ื“ืงื• ืฉื›ืœ ื”ืคืชืจื•ืŸ ื‘ืจื•ืจ, ื•ืื– ืฉืœื—ื• ืœืžื—ืฉื‘.");send.focus();
223
  }catch(error){say("ืœื ื ื™ืชืŸ ืœื”ื›ื™ืŸ ืืช ื”ืชืžื•ื ื”. ื ืกื• ืœืฆืœื ืฉื•ื‘.");}
224
  finally{if(valid())controls(false);}
225
  };
226
+ const success=()=>{c.photo=null;finish("โœ“ ื”ืชืฉื•ื‘ื” ื ืฉืœื—ื” ืœืžื—ืฉื‘.");};
227
  send.onclick=async()=>{
228
  if(!c.photo||c.busy)return;
229
  controls(true);say(states.uploading);
230
  try{
231
+ await confirmed();if(!valid())return;
232
  c.key=c.key||crypto.randomUUID();
233
  let b=await post(`/api/math/answer-scans/${c.result.scan.id}/uploads`,{key:c.key});if(!valid())return;
234
  if(b.attachment){success();return;}
 
239
  if(!valid())return;say(error.message+" ื”ืชืžื•ื ื” ื ืฉืžืจื” ืœืชืฆื•ื’ื” ืžืงื“ื™ืžื”. ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘.");
240
  if(error.status===410||error.code==="superseded")expired();
241
  // Reuse the key after an uncertain response; the server returns prior success.
242
+ }finally{if(valid()&&!c.ended)controls(false);}
243
+ };
244
+ decline.onclick=async()=>{
245
+ controls(true);
246
+ try{await post(`/api/math/answer-scans/${c.result.scan.id}/decline`);if(!valid())return;finish("ืกื™ืจื‘ืชื ืœื‘ืงืฉื”. ื”ืžื—ืฉื‘ ืงื™ื‘ืœ ื”ื•ื“ืขื”.");}
247
+ catch(error){if(!valid())return;if(error.code==="superseded"||error.status===404)finish("ื”ื‘ืงืฉื” ื›ื‘ืจ ืœื ืคืขื™ืœื”.");else say(error.message);}
248
+ finally{if(valid()&&!c.ended)controls(false);}
249
  };
250
  renew.onclick=async()=>{
251
  controls(true);
252
  try{const r=await post(`/api/math/answer-attempts/${c.result.attempt.id}/scans`,{renew:true});if(!valid())return;
253
+ c.result=r;c.key=null;c.confirming=null;renew.hidden=true;take.hidden=false;node.querySelector(".mx-scan-code").outerHTML=codeHTML(r.scan.code);
254
+ say("ื ื•ืฆืจื” ื‘ืงืฉื” ื—ื“ืฉื” ืขื ืžืกืคืจ ื—ื“ืฉ. ืืคืฉืจ ืœืฆืœื.");}
255
  catch(error){say(error.message);}
256
  finally{if(valid())controls(false);}
257
  };
258
  if(result.scan.state==="expired")expired();
259
+ if(navigator.vibrate)try{navigator.vibrate([60,40,60]);}catch{}
260
+ take.focus();
261
+ }
262
+
263
+ /* ---------- the computer: waiting for the phone ---------- */
264
+ /* A window with the request's 4-digit code until the phone answers. Resolves with the attempt's last result (photo
265
+ received, declined, expired or withdrawn here), or null when the account changed. `scanTo`: the body for a new
266
+ request ({question_id} when it belongs to a question). */
267
+ function closeWait(){ if(waiting){clearTimeout(waiting.timer);waiting.node.remove();waiting.returnFocus?.focus?.({preventScroll:true});const done=waiting.done;waiting=null;done(null);} }
268
+ function waitForPhone(result,scanTo={}){
269
+ closeWait();const e=epoch;
270
+ if(!live(e))return Promise.resolve(null);
271
+ return new Promise(done=>{
272
+ const node=document.createElement("div");node.className="mx-scan-overlay mx-scan-wait";node.setAttribute("role","dialog");node.setAttribute("aria-modal","true");node.setAttribute("aria-labelledby","waitTitle");
273
+ node.innerHTML=`<div class="mx-scan-card">
274
+ <h2 id="waitTitle"><span aria-hidden="true">๐Ÿ“ฑ</span> ืฆื™ืœื•ื ื‘ื˜ืœืคื•ืŸ</h2>
275
+ <p data-say>ืคืชื—ื• ืืช Mathly ื‘ื˜ืœืคื•ืŸ, ื‘ืื•ืชื• ื—ืฉื‘ื•ืŸ. ื™ืงืคื•ืฅ ืฉื ื—ืœื•ืŸ ืขื ื”ืžืกืคืจ ื”ื–ื”:</p>
276
+ <div data-code></div>
277
+ <p class="mx-scan-live" data-status role="status" aria-live="polite"><span class="mx-scan-dot" aria-hidden="true"></span><span data-text>ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸโ€ฆ</span></p>
278
+ <div class="mx-scan-actions">
279
+ <button class="mx-btn solid" data-again hidden>ื‘ืงืฉื” ื—ื“ืฉื”</button>
280
+ <button class="mx-btn" data-cancel>ื‘ื™ื˜ื•ืœ ื”ื‘ืงืฉื”</button>
281
+ <button class="mx-btn" data-done hidden>ืกื’ื™ืจื”</button>
282
+ </div></div>`;
283
+ const w=waiting={node,timer:null,result,returnFocus:document.activeElement,done:r=>done(r)};(visibleRoot||document.body).append(node);
284
+ const $=sel=>node.querySelector(sel);
285
+ const valid=()=>waiting===w&&live(e);
286
+ const end=r=>{ if(waiting!==w)return; clearTimeout(w.timer);node.remove();waiting=null;w.returnFocus?.focus?.({preventScroll:true});done(r); };
287
+ let shown=null;
288
+ const paint=()=>{
289
+ const r=w.result,st=r.scan?.state,got=!!r.attachment;
290
+ if(got){ end(r); return; }
291
+ if(shown!==r.scan?.id){ shown=r.scan?.id; $("[data-code]").innerHTML=codeHTML(r.scan?.code); }
292
+ const over=["declined","expired","superseded"].includes(st);
293
+ const text={waiting:"ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸโ€ฆ",confirmed:"ื”ื˜ืœืคื•ืŸ ืื™ืฉืจ. ืžืžืชื™ื ื™ื ืœืฆื™ืœื•ืโ€ฆ",uploading:"ืžืงื‘ืœื™ื ืืช ื”ืชืžื•ื ื” ืžื”ื˜ืœืคื•ืŸโ€ฆ",failed:"ื”ื”ืขืœืื” ืžื”ื˜ืœืคื•ืŸ ื ื›ืฉืœื”. ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘ ื‘ื˜ืœืคื•ืŸ.",
294
+ declined:"ื”ื‘ืงืฉื” ื ื“ื—ืชื” ื‘ื˜ืœืคื•ืŸ.",expired:"ื”ื‘ืงืฉื” ืคื’ื”. ืืคืฉืจ ืœื™ืฆื•ืจ ื‘ืงืฉื” ื—ื“ืฉื”.",superseded:"ื”ื‘ืงืฉื” ื”ื•ื—ืœืคื”."}[st]||"ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸโ€ฆ";
295
+ $("[data-text]").textContent=text;
296
+ node.classList.toggle("is-over",over);node.classList.toggle("is-declined",st==="declined");
297
+ $("[data-say]").hidden=over;$("[data-code]").hidden=over;
298
+ $("[data-again]").hidden=$("[data-done]").hidden=!over;$("[data-cancel]").hidden=over||st==="uploading";
299
+ };
300
+ const poll=async()=>{
301
+ clearTimeout(w.timer);if(!valid())return;
302
+ try{ const r=await json(`/api/math/answer-attempts/${w.result.attempt.id}`);if(!valid())return;w.result=r;paint(); }
303
+ catch(error){ if(!valid())return;$("[data-text]").textContent=error.message; }
304
+ if(valid()&&!["declined","expired","superseded"].includes(w.result.scan?.state))w.timer=setTimeout(poll,document.hidden?5000:2000);
305
+ };
306
+ $("[data-cancel]").onclick=async()=>{
307
+ const b=$("[data-cancel]");b.disabled=true;
308
+ try{ w.result=await post(`/api/math/answer-scans/${w.result.scan.id}/decline`,{});if(!valid())return; }
309
+ catch(error){ if(!valid())return; }
310
+ end(w.result);
311
+ };
312
+ $("[data-done]").onclick=()=>end(w.result);
313
+ $("[data-again]").onclick=async()=>{
314
+ const b=$("[data-again]");b.disabled=true;
315
+ try{ w.result=await post(`/api/math/answer-attempts/${w.result.attempt.id}/scans`,{...scanTo,renew:true});if(!valid())return;paint();poll(); }
316
+ catch(error){ if(valid())$("[data-text]").textContent=error.message; }
317
+ finally{ b.disabled=false; }
318
+ };
319
+ node.onkeydown=event=>{ if(event.key==="Escape"){event.preventDefault();event.stopPropagation();} trapTab(node,event); };
320
+ paint(); if(waiting===w){ $("[data-cancel]").focus(); w.timer=setTimeout(poll,1500); }
321
+ });
322
  }
323
  document.addEventListener("visibilitychange",()=>{
324
  if(document.hidden)return;
 
326
  if(question?.result)pollQuestion(question);
327
  });
328
  window.addEventListener("online",()=>{if(account&&inbox)pollInbox(epoch);if(question?.result)pollQuestion(question);});
329
+ window.MathlyAnswers={configure,mountQuestion,closeQuestion,reset,waitForPhone};
330
  })();
static/math-sky.js CHANGED
@@ -5,7 +5,8 @@
5
  comes back for spaced review (`review`, after 1, 3, 7, 14, 30 and 60 days; a miss starts over). A finished skill not practised for
6
  FADE_DAYS fades until one correct answer brightens it again. GOAL correct answers make a day; days in a row make the
7
  streak, and one missed day does not break it. Now and then a comet crosses the screen and can be caught for the
8
- collection. All of it lives in the student's progress (P, see math.js): run, gold, seen, days, comets, cometDay.
 
9
  Pure rules first, then the drawings (owl, constellations), then the effects (sound, sparkles, overlays). */
10
  (function(){
11
  "use strict";
@@ -19,7 +20,7 @@ const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&
19
  /* ---------- rules ---------- */
20
  const dayKey=(d=new Date())=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
21
  const daysBack=(n,from)=>{const d=new Date(from);d.setDate(d.getDate()-n);return d};
22
- function ensure(P){ for(const k of ["run","gold","seen","days","review","missed"]) if(!P[k]||typeof P[k]!=="object") P[k]={}; if(!Array.isArray(P.comets)) P.comets=[]; return P; }
23
  function fading(P,id,now=new Date()){
24
  if(!P.done?.[id]) return false;
25
  const t=Date.parse(P.seen?.[id]||P.done[id]);
@@ -53,6 +54,40 @@ function due(P,id,now=new Date()){
53
  const r=P.review?.[id]; if(r?.due) return r.due<=dayKey(now);
54
  const t=Date.parse(P.seen?.[id]||P.done[id]); return !isNaN(t)&&now-t>=DAY;
55
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  /* a star's look: gold, lit, fading (finished), next, open, locked, off */
57
  function star(P,id,status,isNext){
58
  if(status==="done") return P.gold?.[id]?"gold":fading(P,id)?"fading":"lit";
@@ -315,6 +350,6 @@ function comet(onCatch){
315
  }
316
  const cometCard=c=>`<div class="sky-card"><span class="sym">${esc(c.sym)}</span><b>${esc(c.name)}</b></div>`;
317
 
318
- window.MathlySky={GOAL,FADE_DAYS,MASTER_RUN,GOLD_RUN,masterNeed,goldNeed,mastered,reviewed,due,COMETS,SPARK,spark,FLAME,owl,praise,dayKey,ensure,correct,wrong,fading,star,today,streak,dayState,month,best,
319
  cometDue,catchComet,shape,labels,mount,burst,burstAt,sound,buzz,soundOn,setSound,toast,celebrate,comet,cometCard};
320
  })();
 
5
  comes back for spaced review (`review`, after 1, 3, 7, 14, 30 and 60 days; a miss starts over). A finished skill not practised for
6
  FADE_DAYS fades until one correct answer brightens it again. GOAL correct answers make a day; days in a row make the
7
  streak, and one missed day does not break it. Now and then a comet crosses the screen and can be caught for the
8
+ collection. All of it lives in the student's progress (P, see math.js): run, gold, seen, days, comets, cometDay, and the
9
+ misconceptions met along the way (slips, see "misconception patterns").
10
  Pure rules first, then the drawings (owl, constellations), then the effects (sound, sparkles, overlays). */
11
  (function(){
12
  "use strict";
 
20
  /* ---------- rules ---------- */
21
  const dayKey=(d=new Date())=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
22
  const daysBack=(n,from)=>{const d=new Date(from);d.setDate(d.getDate()-n);return d};
23
+ function ensure(P){ for(const k of ["run","gold","seen","days","review","missed"]) if(!P[k]||typeof P[k]!=="object") P[k]={}; for(const k of ["comets","slips"]) if(!Array.isArray(P[k])) P[k]=[]; return P; }
24
  function fading(P,id,now=new Date()){
25
  if(!P.done?.[id]) return false;
26
  const t=Date.parse(P.seen?.[id]||P.done[id]);
 
54
  const r=P.review?.[id]; if(r?.due) return r.due<=dayKey(now);
55
  const t=Date.parse(P.seen?.[id]||P.done[id]); return !isNaN(t)&&now-t>=DAY;
56
  }
57
+ /* ---------- misconception patterns ----------
58
+ Every tagged wrong answer is kept in P.slips ({at, id: skill, q: question index, m: mistake tag, via: practice |
59
+ review | exam}), newest last, at most SLIP_MAX. patterns() reads it: a tag is recurring when it came up at least
60
+ twice in RECUR_DAYS and once in the last QUIET_DAYS; it has stopped when it once came up twice within RECUR_DAYS,
61
+ but not in the last QUIET_DAYS, and less than GONE_DAYS ago. */
62
+ const SLIP_MAX=500, RECUR_DAYS=30, QUIET_DAYS=14, GONE_DAYS=60, PATTERNS_SHOWN=3;
63
+ function slip(P,id,q,m,via,now=new Date()){
64
+ ensure(P); if(!m) return;
65
+ P.slips.push({at:now.toISOString(),id,q,m,via});
66
+ if(P.slips.length>SLIP_MAX) P.slips.splice(0,P.slips.length-SLIP_MAX);
67
+ }
68
+ // `mistakes`: the graph's mistakes by tag (for the skill each one sends back to)
69
+ function patterns(P,mistakes={},now=new Date()){
70
+ const by={};
71
+ for(const s of P.slips||[]){ const t=Date.parse(s?.at); if(s?.m&&!isNaN(t)&&t<=now) (by[s.m]||=[]).push({t,id:s.id}); }
72
+ const recurring=[], stopped=[], age=t=>(now-t)/DAY;
73
+ for(const [m,all] of Object.entries(by)){
74
+ all.sort((a,b)=>a.t-b.t);
75
+ const last=all.at(-1).t;
76
+ if(age(last)>=GONE_DAYS) continue;
77
+ const recent=all.filter(x=>age(x.t)<RECUR_DAYS);
78
+ const item=list=>{
79
+ const count={}, seen={};
80
+ for(const x of list){ count[x.id]=(count[x.id]||0)+1; seen[x.id]=x.t; }
81
+ const skills=Object.keys(count).sort((a,b)=>count[b]-count[a]||seen[b]-seen[a]);
82
+ return {m,n:list.length,skills,last:new Date(last).toISOString(),practise:mistakes[m]?.back||skills[0]};
83
+ };
84
+ if(age(last)<QUIET_DAYS){ if(recent.length>=2) recurring.push(item(recent)); }
85
+ else if(all.some((x,i)=>i>0&&(x.t-all[i-1].t)/DAY<=RECUR_DAYS)) stopped.push(item(all));
86
+ }
87
+ const rank=(a,b)=>b.skills.length-a.skills.length||b.n-a.n||b.last.localeCompare(a.last);
88
+ return {recurring:recurring.sort(rank).slice(0,PATTERNS_SHOWN),stopped:stopped.sort(rank).slice(0,PATTERNS_SHOWN)};
89
+ }
90
+
91
  /* a star's look: gold, lit, fading (finished), next, open, locked, off */
92
  function star(P,id,status,isNext){
93
  if(status==="done") return P.gold?.[id]?"gold":fading(P,id)?"fading":"lit";
 
350
  }
351
  const cometCard=c=>`<div class="sky-card"><span class="sym">${esc(c.sym)}</span><b>${esc(c.name)}</b></div>`;
352
 
353
+ window.MathlySky={GOAL,slip,patterns,FADE_DAYS,MASTER_RUN,GOLD_RUN,masterNeed,goldNeed,mastered,reviewed,due,COMETS,SPARK,spark,FLAME,owl,praise,dayKey,ensure,correct,wrong,fading,star,today,streak,dayState,month,best,
354
  cometDue,catchComet,shape,labels,mount,burst,burstAt,sound,buzz,soundOn,setSound,toast,celebrate,comet,cometCard};
355
  })();
static/math.css CHANGED
@@ -136,9 +136,25 @@ html.math-on, html.math-on body{overflow:hidden}
136
  .mx-answer{display:flex;flex-direction:column;gap:12px}
137
  .mx-answer [hidden]{display:none}
138
  .mx-answer-image{display:block;max-width:100%;max-height:420px;object-fit:contain;border-radius:12px;background:#fff}
139
- .mx-scan-inbox:empty{display:none}.mx-scan-inbox{display:flex;flex-direction:column;gap:12px;padding-top:24px}
140
- .mx-scan-inbox .mx-btn{display:flex;flex-direction:column;align-items:flex-start;text-align:start;white-space:normal;gap:8px}
141
- .mx-scan-overlay{position:fixed;inset:0;z-index:1000;background:#000c;overflow:auto;padding:20px;display:flex;align-items:flex-start;justify-content:center}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  .mx-scan-card{background:#141719;color:#f4f2ec;border:1px solid #485050;border-radius:18px;padding:24px;width:min(520px,100%);display:flex;flex-direction:column;gap:14px;line-height:1.6}
143
  .mx-scan-card p{margin:0}.mx-scan-card [hidden]{display:none}.mx-scan-card small{color:#adb7b1}
144
  .mx-management,.mx-class-editor form{display:flex;flex-direction:column;gap:16px}
@@ -353,10 +369,10 @@ line.draw{stroke-dasharray:500;stroke-dashoffset:500;animation:sky-draw 1.1s eas
353
  /* today's goal, above the path; opens the streak calendar */
354
  .mx-daily{display:flex;flex-direction:column;gap:8px;width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:18px;background:var(--surface);color:var(--ink);font:inherit;text-align:start;cursor:pointer;transition:border-color .15s}
355
  .mx-daily:hover{border-color:var(--ember)}
356
- .mx-review{display:flex;flex-direction:column;gap:4px;width:100%;margin-top:10px;padding:12px 14px;border:1px solid var(--line);border-inline-start:4px solid var(--gold);border-radius:18px;background:var(--surface);color:var(--ink);font:inherit;text-align:start;cursor:pointer;transition:border-color .15s}
357
- .mx-review:hover{border-color:var(--gold)}
358
  #mathView .mx-review-sheet{--uc:var(--starlight)} /* neutral: a unit colour would give the topic away */
359
- .mx-review span{color:var(--muted);font-size:13px}.mx-review .mx-dgo{color:var(--gold)}
360
  .mx-daily small{color:var(--muted);font-size:13px}
361
  .mx-dline{display:flex;align-items:center;gap:8px;font-size:15px}
362
  .mx-dline b{flex:1;min-width:0}
@@ -402,19 +418,34 @@ button.sky-chip.flame{padding:6px 11px;color:var(--ink)}
402
  .k-met{background:var(--ember)}.k-saved{background:var(--ice-bg);box-shadow:inset 0 0 0 2px var(--ice)}.k-some{width:6px!important;height:6px!important;background:var(--ember)}
403
  .mx-examsoon{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:11px 14px;border:1px solid color-mix(in srgb,var(--ember) 50%,var(--line));border-radius:14px;background:var(--ember-bg);color:var(--ink);font-size:15px;text-align:start;cursor:pointer}
404
  .mx-examsoon span{color:var(--muted);font-size:13px;white-space:nowrap}
405
- /* one winding trail: start, then each unit's banner and its skills, then the finish; the line is drawn behind */
406
- .mx-trail{position:relative;display:flex;flex-direction:column;align-items:center;gap:22px;padding-block:6px 10px;--amp:min(90px,22vw)}
407
- .mx-line{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}
408
- .mx-line path{fill:none;stroke:var(--line);stroke-width:7;stroke-linecap:round;stroke-dasharray:1 13}
409
- .mx-line path.on{stroke:var(--uc,var(--aurora));stroke-dasharray:none;stroke-opacity:.85}
410
  .mx-start,.mx-finish{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;font:400 15px/1.2 var(--f-display);color:var(--muted)}
411
  .mx-flag{display:grid;place-items:center;width:64px;height:64px;border-radius:50%;background:var(--surface-2);border:3px solid var(--aurora)}
412
  .mx-start b{color:var(--aurora)}
413
  .mx-finish>svg{width:30px;height:30px;fill:var(--dim)}
414
- .mx-unit{position:relative;width:100%;padding:12px 16px;border-radius:16px;background:var(--surface);border:1px solid color-mix(in srgb,var(--uc) 45%,var(--line));scroll-margin-top:72px}
 
 
 
 
 
 
 
 
415
  .mx-uhead{display:flex;align-items:center;gap:4px 10px;flex-wrap:wrap}
416
- .mx-uhead h2{font-size:20px;color:var(--uc);flex:1;min-width:0}
417
- .mx-unit.full .mx-uhead h2::after{content:" โœฆ";color:var(--gold)}
 
 
 
 
 
 
 
 
418
  /* lesson resources: a teacher opens them from a unit's title (static/math.js, openResources) */
419
  .mx-uhead.is-teach{cursor:pointer}
420
  .mx-res-open{border:1px solid color-mix(in srgb,var(--uc) 55%,var(--line));background:var(--bg);color:var(--ink);border-radius:999px;padding:6px 12px;font:600 13px/1 var(--f-body);cursor:pointer;min-height:32px}
@@ -440,7 +471,21 @@ button.sky-chip.flame{padding:6px 11px;color:var(--ink)}
440
  @media (max-width:380px){.mx-res-kinds{grid-template-columns:1fr}}
441
  .mx-pill{padding:3px 10px;border-radius:999px;font:700 12px/1.4 var(--f-body);background:color-mix(in srgb,var(--uc) 18%,transparent);color:var(--uc);font-variant-numeric:tabular-nums}
442
  .mx-usub{width:100%;color:var(--muted);font-size:13px}
443
- .mx-step{position:relative;display:flex;flex-direction:column;align-items:center;gap:6px;width:150px;transform:translateX(calc(var(--x) * var(--amp)))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  .mx-star{position:relative;width:62px;height:62px;padding:0;border-radius:50%;display:grid;place-items:center;cursor:pointer;
445
  background:var(--surface-2);border:3px solid var(--line);box-shadow:0 5px 0 var(--night-2);transition:transform .15s}
446
  .mx-star:active{transform:translateY(3px);box-shadow:0 2px 0 var(--night-2)}
@@ -545,3 +590,63 @@ button.sky-chip.flame{padding:6px 11px;color:var(--ink)}
545
  line.draw{stroke-dashoffset:0}
546
  .sky-comet{animation:none;left:auto;right:24px;top:30%}
547
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  .mx-answer{display:flex;flex-direction:column;gap:12px}
137
  .mx-answer [hidden]{display:none}
138
  .mx-answer-image{display:block;max-width:100%;max-height:420px;object-fit:contain;border-radius:12px;background:#fff}
139
+ /* photo by phone (static/answer-scans.js): a window on the phone with the request, and one on the computer while it waits;
140
+ both show the same 4-digit code */
141
+ .mx-scan-overlay{position:fixed;inset:0;z-index:1000;background:#000c;overflow:auto;padding:20px;display:flex;align-items:center;justify-content:center;animation:mx-scan-in .2s ease-out}
142
+ @keyframes mx-scan-in{from{opacity:0;transform:scale(.97)}}
143
+ .mx-scan-card h2{margin:0;font-size:22px}
144
+ .mx-scan-code{display:flex;justify-content:center;gap:10px;margin:2px 0}
145
+ .mx-scan-code span{display:grid;place-items:center;width:58px;height:72px;border-radius:14px;background:#0b0d0e;border:2px solid var(--mx-accent);
146
+ color:#fff;font:800 40px/1 var(--f-body,system-ui);font-variant-numeric:tabular-nums;box-shadow:0 0 18px color-mix(in srgb,var(--mx-accent) 30%,transparent)}
147
+ .mx-scan-q{padding:10px 12px;border-radius:12px;background:#0b0d0e;border:1px solid #2c3333}
148
+ .mx-scan-actions{display:flex;flex-direction:column;gap:10px}
149
+ .mx-scan-actions .mx-btn{min-height:50px;font-size:17px}
150
+ .mx-btn-quiet{border-color:#485050;color:#d7dcd8}
151
+ .mx-scan-live{display:flex;align-items:center;justify-content:center;gap:10px;font-weight:700}
152
+ .mx-scan-dot{width:10px;height:10px;border-radius:50%;background:var(--mx-accent);animation:sky-pulse 1.4s ease-out infinite}
153
+ .mx-scan-wait.is-over .mx-scan-dot{display:none}
154
+ .mx-scan-wait.is-declined .mx-scan-live{color:var(--amber,#f0b35a)}
155
+ .mx-scan-wait .mx-scan-card{text-align:center}
156
+ .mx-phone-cta{font-size:15px;padding:12px 18px}
157
+ @media (prefers-reduced-motion:reduce){.mx-scan-overlay,.mx-scan-dot{animation:none}}
158
  .mx-scan-card{background:#141719;color:#f4f2ec;border:1px solid #485050;border-radius:18px;padding:24px;width:min(520px,100%);display:flex;flex-direction:column;gap:14px;line-height:1.6}
159
  .mx-scan-card p{margin:0}.mx-scan-card [hidden]{display:none}.mx-scan-card small{color:#adb7b1}
160
  .mx-management,.mx-class-editor form{display:flex;flex-direction:column;gap:16px}
 
369
  /* today's goal, above the path; opens the streak calendar */
370
  .mx-daily{display:flex;flex-direction:column;gap:8px;width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:18px;background:var(--surface);color:var(--ink);font:inherit;text-align:start;cursor:pointer;transition:border-color .15s}
371
  .mx-daily:hover{border-color:var(--ember)}
372
+ .mx-review,.mx-quiz-card{display:flex;flex-direction:column;gap:4px;width:100%;margin-top:10px;padding:12px 14px;border:1px solid var(--line);border-inline-start:4px solid var(--gold);border-radius:18px;background:var(--surface);color:var(--ink);font:inherit;text-align:start;cursor:pointer;transition:border-color .15s}
373
+ .mx-review:hover,.mx-quiz-card:hover{border-color:var(--gold)}
374
  #mathView .mx-review-sheet{--uc:var(--starlight)} /* neutral: a unit colour would give the topic away */
375
+ .mx-review span,.mx-quiz-card span{color:var(--muted);font-size:13px}.mx-review .mx-dgo,.mx-quiz-card .mx-dgo{color:var(--gold)}
376
  .mx-daily small{color:var(--muted);font-size:13px}
377
  .mx-dline{display:flex;align-items:center;gap:8px;font-size:15px}
378
  .mx-dline b{flex:1;min-width:0}
 
418
  .k-met{background:var(--ember)}.k-saved{background:var(--ice-bg);box-shadow:inset 0 0 0 2px var(--ice)}.k-some{width:6px!important;height:6px!important;background:var(--ember)}
419
  .mx-examsoon{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:11px 14px;border:1px solid color-mix(in srgb,var(--ember) 50%,var(--line));border-radius:14px;background:var(--ember-bg);color:var(--ink);font-size:15px;text-align:start;cursor:pointer}
420
  .mx-examsoon span{color:var(--muted);font-size:13px;white-space:nowrap}
421
+ /* the path: start, then each unit's orbit (and its quiz), then the finish; a dotted line joins them top to bottom */
422
+ .mx-trail{position:relative;display:flex;flex-direction:column;align-items:center;gap:30px;padding-block:6px 10px;container-type:inline-size}
423
+ .mx-trail>:not(:first-child)::before{content:"";position:absolute;bottom:100%;left:50%;width:6px;height:30px;margin-left:-3px;
424
+ background:radial-gradient(circle,var(--line) 2.6px,transparent 3px) center top/6px 10px repeat-y;pointer-events:none}
 
425
  .mx-start,.mx-finish{position:relative;display:flex;flex-direction:column;align-items:center;gap:4px;font:400 15px/1.2 var(--f-display);color:var(--muted)}
426
  .mx-flag{display:grid;place-items:center;width:64px;height:64px;border-radius:50%;background:var(--surface-2);border:3px solid var(--aurora)}
427
  .mx-start b{color:var(--aurora)}
428
  .mx-finish>svg{width:30px;height:30px;fill:var(--dim)}
429
+ /* a unit's orbit: the unit in the middle, its skills around it in order (math.js, pathView) */
430
+ .mx-unit{position:relative;display:flex;flex-direction:column;align-items:center;gap:8px;width:100%;scroll-margin-top:72px}
431
+ .mx-stage{--s:min(400px,100cqw);position:relative;width:var(--s);height:var(--s);margin-block:34px 30px}
432
+ .mx-ring{position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none}
433
+ .mx-ring path{fill:none;stroke:var(--line);stroke-width:1.6;stroke-linecap:round;stroke-dasharray:.1 3.2}
434
+ .mx-ring path.on{stroke:var(--uc);stroke-dasharray:none;stroke-opacity:.9}
435
+ .mx-ring .mx-arrow{stroke-width:1.1;stroke-dasharray:none;stroke-linejoin:round;stroke:var(--dim)}
436
+ .mx-ring .mx-arrow.on{stroke:var(--uc)}
437
+ .mx-ring .mx-back{stroke-opacity:.35}
438
  .mx-uhead{display:flex;align-items:center;gap:4px 10px;flex-wrap:wrap}
439
+ .mx-hub{position:absolute;left:50%;top:50%;width:calc(var(--s) * .36);aspect-ratio:1;transform:translate(-50%,-50%);border-radius:50%;
440
+ flex-direction:column;justify-content:center;flex-wrap:nowrap;gap:4px;padding:12px;box-sizing:border-box;text-align:center;
441
+ background:radial-gradient(circle,color-mix(in srgb,var(--uc) 22%,var(--surface)) 0 calc(100% - 7px),transparent calc(100% - 6px)),
442
+ conic-gradient(var(--uc) calc(var(--p) * 1%),var(--surface-2) 0);
443
+ box-shadow:0 6px 0 var(--night-2),0 0 26px color-mix(in srgb,var(--uc) 30%,transparent)}
444
+ .mx-hub h2{font-size:clamp(14px,4.2cqw,18px);line-height:1.15;color:var(--ink);margin:0;overflow-wrap:anywhere}
445
+ .mx-hub .mx-pill{background:color-mix(in srgb,var(--uc) 35%,var(--bg));color:var(--ink)}
446
+ .mx-hub .mx-usub{width:auto;font-size:11.5px}
447
+ .mx-unit.full .mx-hub{box-shadow:0 6px 0 var(--night-2),0 0 30px color-mix(in srgb,var(--gold) 55%,transparent)}
448
+ .mx-unit.full .mx-hub h2::after{content:" โœฆ";color:var(--gold)}
449
  /* lesson resources: a teacher opens them from a unit's title (static/math.js, openResources) */
450
  .mx-uhead.is-teach{cursor:pointer}
451
  .mx-res-open{border:1px solid color-mix(in srgb,var(--uc) 55%,var(--line));background:var(--bg);color:var(--ink);border-radius:999px;padding:6px 12px;font:600 13px/1 var(--f-body);cursor:pointer;min-height:32px}
 
471
  @media (max-width:380px){.mx-res-kinds{grid-template-columns:1fr}}
472
  .mx-pill{padding:3px 10px;border-radius:999px;font:700 12px/1.4 var(--f-body);background:color-mix(in srgb,var(--uc) 18%,transparent);color:var(--uc);font-variant-numeric:tabular-nums}
473
  .mx-usub{width:100%;color:var(--muted);font-size:13px}
474
+ .mx-step{position:relative;display:flex;flex-direction:column;align-items:center;gap:6px;width:150px}
475
+ /* a skill on the orbit: its centre sits on the ring; the name goes above it in the top part, below it elsewhere */
476
+ .mx-orb{position:absolute;width:0;height:0;gap:0;left:calc(50% + var(--dx) * var(--s) * .34);top:calc(50% + var(--dy) * var(--s) * .34)}
477
+ .mx-orb>.mx-star{position:absolute;left:0;top:0;width:54px;height:54px;transform:translate(-50%,-50%)}
478
+ .mx-orb>.mx-star:active{transform:translate(-50%,calc(-50% + 3px))}
479
+ .mx-orb>.mx-star.st-next{width:64px;height:64px}
480
+ .mx-orb>.mx-lbl{position:absolute;left:0;top:32px;transform:translateX(-50%);width:max-content;max-width:min(112px,28cqw);padding:2px 7px;font-size:12px}
481
+ .mx-orb .mx-perch{top:20px;left:-30px}
482
+ .mx-orb.up>.mx-lbl{top:auto;bottom:32px}
483
+ .mx-orb>.st-next+.mx-lbl{top:38px}.mx-orb.up>.st-next+.mx-lbl{top:auto;bottom:38px}
484
+ .mx-num{position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;padding:0 4px;box-sizing:border-box;border-radius:999px;display:grid;place-items:center;
485
+ font:700 11.5px/1 var(--f-body);font-variant-numeric:tabular-nums;background:var(--bg);color:var(--muted);border:2px solid var(--line)}
486
+ .mx-star.st-lit .mx-num,.mx-star.st-fading .mx-num{border-color:var(--uc);color:var(--ink)}
487
+ .mx-star.st-gold .mx-num{border-color:var(--gold);color:var(--gold)}
488
+ .mx-star.st-next .mx-num{border-color:var(--aurora);background:var(--aurora);color:var(--on-aurora)}
489
  .mx-star{position:relative;width:62px;height:62px;padding:0;border-radius:50%;display:grid;place-items:center;cursor:pointer;
490
  background:var(--surface-2);border:3px solid var(--line);box-shadow:0 5px 0 var(--night-2);transition:transform .15s}
491
  .mx-star:active{transform:translateY(3px);box-shadow:0 2px 0 var(--night-2)}
 
590
  line.draw{stroke-dashoffset:0}
591
  .sky-comet{animation:none;left:auto;right:24px;top:30%}
592
  }
593
+
594
+ /* the practice exam (math.js, "practice exam"); its card looks like the review's */
595
+ .mx-quiz-card{border-inline-start-color:var(--aurora)}.mx-quiz-card .mx-dgo{color:var(--aurora)}
596
+ .mx-quiz-units{display:flex;flex-wrap:wrap;gap:8px}
597
+ .mx-quiz-units button[aria-pressed="true"]{background:var(--mx-accent);color:var(--mx-on-accent)}
598
+ .mx-quiz-units button[aria-pressed="false"]{opacity:.7}
599
+ .mx-quiz-dots{display:flex;flex-wrap:wrap;gap:6px}
600
+ .mx-quiz-dots button{width:30px;height:30px;border-radius:50%;border:1px solid var(--line);background:var(--surface);color:var(--muted);font:600 13px/1 var(--f-body);cursor:pointer}
601
+ .mx-quiz-dots button.done{border-color:var(--mx-accent);color:var(--ink)}
602
+ .mx-quiz-dots button.now{outline:2px solid var(--gold);outline-offset:2px}
603
+ .mx-quiz-sheet .mx-choices button[aria-pressed="true"]{border-color:var(--gold);background:var(--surface-2);box-shadow:inset 0 0 0 1px var(--gold)}
604
+ .mx-quiz-nav{display:flex;justify-content:space-between;gap:8px;margin-top:4px}
605
+ .mx-quiz-warn{color:var(--amber)}
606
+ .mx-quiz-units-score{display:grid;gap:10px}
607
+ .mx-quiz-units-score div{display:grid;grid-template-columns:1fr auto;gap:4px 8px;align-items:center}
608
+ .mx-quiz-units-score .mx-meter{grid-column:1/-1;height:8px;background:var(--surface-2)}
609
+ .mx-quiz-units-score .mx-meter i{background:var(--aurora)}
610
+ .mx-quiz-plan{display:flex;flex-direction:column;gap:10px;padding-inline-start:20px;margin:0}
611
+ .mx-quiz-plan li{display:flex;flex-direction:column;gap:4px;background:var(--amber-bg);border-radius:12px;padding:10px 12px}
612
+ .mx-quiz-plan li b{color:var(--amber)}
613
+ .mx-quiz-review summary{cursor:pointer;color:var(--muted)}
614
+ .mx-quiz-review ol{display:flex;flex-direction:column;gap:8px;padding-inline-start:20px}
615
+ .mx-quiz-review li{display:flex;flex-direction:column;gap:2px}
616
+ .mx-quiz-review li small{color:var(--dim)}
617
+ .mx-quiz-review li.ok>span:last-child{color:var(--ok)}
618
+ .mx-quiz-review li.bad>span:last-child{color:var(--amber)}
619
+
620
+ /* the unit quiz (math.js, "unit quiz"): a node at the end of the unit, and a sheet that looks like a paper quiz */
621
+ .mx-uquiz{width:60px;height:60px;padding:0;border-radius:18px;font-size:24px;display:grid;place-items:center;border:2px dashed var(--uc,var(--gold));background:var(--surface);cursor:pointer;box-shadow:0 5px 0 var(--night-2)}
622
+ .mx-uquiz.done{border-style:solid}
623
+ .mx-uq-rules{display:flex;flex-direction:column;gap:6px;padding-inline-start:20px;margin:0;color:var(--ink)}
624
+ .mx-uq-history{display:flex;flex-wrap:wrap;gap:6px;align-items:center}.mx-uq-history h4{width:100%;margin:0}
625
+ .mx-uq-head{position:sticky;top:-16px;z-index:2;display:flex;align-items:center;gap:10px;justify-content:space-between;background:var(--surface);padding:16px 0 6px;margin-top:-16px;border-bottom:1px solid var(--line)} /* covers the sheet's top padding too */
626
+ .mx-uq-head h3{margin:2px 0 0}
627
+ .mx-uq-clock{font:700 22px/1 var(--f-body);font-variant-numeric:tabular-nums;color:var(--ink);padding:6px 12px;border-radius:999px;background:var(--surface-2);margin-inline-start:auto}
628
+ .mx-uq-clock.low{color:var(--amber)}.mx-uq-clock.over{color:var(--amber);background:var(--amber-bg)}
629
+ .mx-uq-banner{margin:0;color:var(--amber);font-size:14px}.mx-uq-banner:empty{display:none}
630
+ .mx-uq-qs{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:14px}
631
+ .mx-uq-q{background:var(--bg);border-radius:14px;padding:14px;display:flex;flex-direction:column;gap:8px}
632
+ .mx-uq-qhead{display:flex;justify-content:space-between;align-items:baseline}.mx-uq-qhead span{color:var(--muted);font-size:14px}
633
+ .mx-uq-text{margin:0;line-height:1.7}
634
+ .mx-uq-answer{display:flex;flex-direction:column;gap:6px;border-top:1px dashed var(--line);padding-top:8px}
635
+ .mx-uq-img{max-width:100%;max-height:320px;object-fit:contain;border-radius:10px;border:1px solid var(--line);align-self:flex-start}
636
+ .mx-uq-verdict{margin:0;font-weight:700}.mx-uq-verdict.correct{color:var(--ok)}.mx-uq-verdict.partial,.mx-uq-verdict.incorrect,.mx-uq-verdict.none,.mx-uq-verdict.unreadable,.mx-uq-verdict.needs_review{color:var(--amber)}
637
+
638
+
639
+ /* misconception patterns: a card on home (after the review) and its sheet */
640
+ .mx-patterns{display:flex;flex-direction:column;gap:4px;width:100%;margin-top:10px;padding:12px 14px;border:1px solid var(--line);border-inline-start:4px solid var(--starlight);border-radius:18px;background:var(--surface);color:var(--ink);font:inherit;text-align:start;cursor:pointer;transition:border-color .15s}
641
+ .mx-patterns:hover{border-color:var(--starlight)}
642
+ .mx-patterns span{color:var(--muted);font-size:13px;overflow-wrap:anywhere}.mx-patterns .mx-dgo{color:var(--starlight)}
643
+ #mathView .mx-patterns-sheet{--uc:var(--starlight)}
644
+ .mx-pattern-list{display:flex;flex-direction:column;gap:10px;margin:12px 0;padding:0;list-style:none}
645
+ .mx-pattern-list li{display:flex;flex-direction:column;align-items:flex-start;gap:6px;padding:12px;border:1px solid var(--line);border-radius:14px;overflow-wrap:anywhere}
646
+ .mx-pattern-list li>span{color:var(--muted);font-size:14px}
647
+ .mx-pattern-list.gone li{opacity:.85;border-style:dashed}
648
+ .mx-patterns-sheet h4{margin:18px 0 0}.mx-pattern-list.gone{margin-top:8px}
649
+ /* a skill that a recurring mistake sends back to: a gold ring on its star and its tag in gold, so it shows on the path */
650
+ .mx-star.fix{box-shadow:0 0 0 3px var(--gold),0 0 14px color-mix(in srgb,var(--gold) 55%,transparent)}
651
+ .mx-lbl.l-fix em{background:var(--gold);color:#2A1B00}
652
+ .mx-pattern-note{display:block;margin-top:6px;padding:8px 10px;border-inline-start:3px solid var(--gold);border-radius:8px;background:color-mix(in srgb,var(--gold) 10%,transparent);color:var(--ink);font-size:13.5px}
static/math.js CHANGED
@@ -7,9 +7,11 @@
7
  shuffled each time a question is shown, and a wrong answer does not reveal the right one, so a question that comes
8
  back is solved again, not remembered. Finished skills come back in a mixed review (see "spaced review" below). Progress is kept on this device, per account (mathly.progress.v1:<email>): `done` per
9
  skill, and `solved` (indexes answered correctly), `at` (the question to show) and `set` (the round's questions, see round())
10
- for skills with a list. */
 
11
  (()=>{
12
  const css=document.createElement("link"); css.rel="stylesheet"; css.href=document.currentScript.src.replace("math.js","math.css");
 
13
  document.head.append(css);
14
 
15
  const CFG=window.MATHLY||{}, AUTH_URL=CFG.supabaseUrl||"";
@@ -28,7 +30,7 @@ const load=()=>{ try{ return JSON.parse(localStorage.getItem(key())||"{}"); }cat
28
  const save=()=>{ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} };
29
 
30
  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="";
31
- let nav=null, FIRST=true, COMBO=0, REV=null; // the tab bar; whether the shown question is still at its first try; first-try answers in a row
32
  let picker=null, PH=null, resPicker=null, RES=null; // RES: the open lesson-resources sheet (see openResources()) // the photo file picker, and the photographed problem on the sheet (see photo())
33
  let advanceTimer=null;
34
  let phonePair=null;
@@ -103,7 +105,7 @@ async function show(){
103
  nav=document.createElement("nav"); nav.className="mx-nav"; nav.setAttribute("aria-label","ื ื™ื•ื•ื˜"); nav.hidden=true;
104
  root.append(path,sheet,picker,resPicker,nav); document.body.append(root); window.MathlySky.mount(root);
105
  root.addEventListener("click",onClick);
106
- document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&cur) closeSheet(); });
107
  }
108
  root.hidden=false;
109
  if(!G){
@@ -153,7 +155,7 @@ function selectSubject(subject){
153
  /* The math screens: home (what you gathered and your streak on top, the path below it) and the collection.
154
  The rules of the stars, the streak and the comets, and their pictures, are in math-sky.js. */
155
  const Sky=window.MathlySky;
156
- let VIEW="path", TRAIL=null; // TRAIL: watches the path's size to redraw its line
157
  const SOUND_ON='<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9h4l5-4v14l-5-4H3z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
158
  const SOUND_OFF='<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9h4l5-4v14l-5-4H3z"/><path d="M16 9l5 6M21 9l-5 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
159
  const TABS=[["path","ื‘ื™ืช",'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 2.5 11h2.7v9.5h5.3v-6h3v6h5.3V11h2.7Z"/></svg>'],
@@ -260,40 +262,47 @@ function examSoon(){
260
  return exam&&exam.days<=14?`<button class="mx-examsoon" data-view="sky"><b>ื”ืžื‘ื—ืŸ ื‘${esc(exam.subject)} ${inDays(exam.days)}</b><span>ืœืœื•ื— ื”ืžื‘ื—ื ื™ื โ€น</span></button>`:"";
261
  }
262
 
263
- /* The path: one winding trail from the start through every skill, unit after unit (a banner opens each unit).
264
- The nodes sway left and right; trail() then draws the line through their centres, lit up to the last skill done. */
265
- const SWAY=[0,.55,.9,.55,0,-.55,-.9,-.55];
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  function pathView(){
267
  const nxt=next(), cls=MY_CLASS?` ยท ื›ื™ืชืช ${esc(MY_CLASS.name)}`:"", teach=can("students.view");
268
- let k=0;
269
  const units=mineUnits().map(u=>{
270
- const ids=unitIds(u), act=ids.filter(live), lit=act.filter(id=>P.done[id]).length;
271
- const steps=ids.map(id=>{ const st=Sky.star(P,id,status(id),id===nxt), tag=st==="next"?"ืžืžืฉื™ื›ื™ื ื›ืืŸ":st==="fading"?"ื“ื•ืขืš":"";
272
- return `<div class="mx-step u-${u.id}" style="--x:${SWAY[k++%SWAY.length]}">
273
- <button class="mx-node mx-star st-${st}" data-id="${id}" aria-label="${esc(plain(BY[id].name))}: ${STATE_NAME[st]}">${Sky.spark()}${st==="next"?`<span class="mx-perch">${Sky.owl(34)}</span>`:""}</button>
274
- <span class="mx-lbl l-${st}">${fmt(BY[id].short)}${tag?`<em>${tag}</em>`:""}</span></div>`; }).join("");
 
 
275
  const res=teach?` data-act="resources" data-unit="${u.id}"`:""; // a teacher opens the lesson's resources from its title
276
- return `<section class="mx-unit u-${u.id}${unitDone(u)?" full":""}" id="mx-unit-${u.id}"><div class="mx-uhead${teach?" is-teach":""}"${res}>
277
- <h2>${esc(u.name)}</h2><span class="mx-pill">${lit}/${act.length}</span>${u.when?`<span class="mx-usub">${esc(u.when)}</span>`:""}
278
- ${teach?`<button class="mx-res-open"${res} aria-label="ืžืฉืื‘ื™ ื”ืฉื™ืขื•ืจ ${esc(u.name)}">๐Ÿ“ท ืžืฉืื‘ื™ื</button>`:""}</div></section>${steps}`;
 
 
 
 
279
  }).join("");
280
- return `${topBar()}${daily()}${reviewCard()}${examSoon()}<div class="mx-pghead"><h2>ื”ืžืกืœื•ืœ ืฉืœื™</h2><p class="mx-sub">ืืœื’ื‘ืจื” ื•ื’ืื•ืžื˜ืจื™ื” ื›ื™ืชื” ื˜ืณ ยท 5 ื™ื—ืดืœ${cls}</p></div>
281
- <div class="mx-trail"><svg class="mx-line" aria-hidden="true"></svg>
282
  <div class="mx-start"><span class="mx-flag">${Sky.owl(40)}</span><b>ื”ืชื—ืœื”</b></div>${units}
283
  <div class="mx-finish" aria-hidden="true">${Sky.spark()}<b>ืกื•ืฃ ื”ืžืกืœื•ืœ</b></div></div>
284
- <p class="mx-note">ื›ืœ ื”ืฉื™ืขื•ืจื™ื ืคืชื•ื—ื™ื. ื›ื•ื›ื‘ ื ื“ืœืง ื›ืฉืขื•ื ื™ื ื ื›ื•ืŸ ืขืœ ื›ืœ ื”ืฉืืœื•ืช ืฉืœ ื”ืžื™ื•ืžื ื•ืช, ื•ื”ื•ืคืš ืœื–ื”ื‘ ืื—ืจื™ ${3} ืชืฉื•ื‘ื•ืช ื ื›ื•ื ื•ืช ื‘ืจืฆืฃ (ืื• ื›ืœ ื”ืฉืืœื•ืช, ื›ืฉื™ืฉ ืคื—ื•ืช). ื›ื•ื›ื‘ ืฉืœื ืชืจื’ืœื• ${Sky.FADE_DAYS} ื™ื•ื ื“ื•ืขืš, ื•ืชืฉื•ื‘ื” ื ื›ื•ื ื” ืื—ืช ืžื—ื–ื™ืจื” ืœื• ืืช ื”ืื•ืจ. ื”ื”ืชืงื“ืžื•ืช ื ืฉืžืจืช ื‘ืžื›ืฉื™ืจ ื”ื–ื”.</p>`;
285
- }
286
- // the trail's line: a smooth curve from the start through each node to the finish; a stretch is lit once the skill it reaches is done
287
- function trail(box){
288
- if(!box?.isConnected) return;
289
- const svg=box.querySelector(".mx-line"), at=box.getBoundingClientRect(), W=box.clientWidth, H=box.scrollHeight;
290
- const mid=el=>{ const r=el.getBoundingClientRect(); return [r.left+r.width/2-at.left,r.top+r.height/2-at.top]; };
291
- const pts=[box.querySelector(".mx-start .mx-flag"),...box.querySelectorAll(".mx-star"),box.querySelector(".mx-finish svg")].map(el=>({p:mid(el),el}));
292
- const f=n=>n.toFixed(1);
293
- svg.setAttribute("viewBox",`0 0 ${W} ${H}`);
294
- svg.innerHTML=pts.slice(1).map(({p:[x1,y1],el},i)=>{ const [x0,y0]=pts[i].p, my=(y0+y1)/2, st=el.classList;
295
- const on=st.contains("st-lit")||st.contains("st-gold")||st.contains("st-fading"), u=el.closest(".mx-step")?.className.match(/u-\w+/)?.[0]||"";
296
- return `<path class="${u}${on?" on":""}" d="M${f(x0)} ${f(y0)}C${f(x0)} ${f(my)} ${f(x1)} ${f(my)} ${f(x1)} ${f(y1)}"/>`; }).join("");
297
  }
298
 
299
  function collectionView(){
@@ -314,15 +323,15 @@ function collectionView(){
314
  function draw(){
315
  Sky.ensure(P);
316
  path.innerHTML=VIEW==="sky"?collectionView():pathView();
317
- TRAIL?.disconnect(); TRAIL=null;
318
- if(VIEW==="path"){ const box=path.querySelector(".mx-trail"); TRAIL=new ResizeObserver(()=>trail(box)); TRAIL.observe(box); }
319
  nav.hidden=false;
320
  nav.innerHTML=`<div>${TABS.map(([v,label,icon])=>`<button data-view="${v}"${VIEW===v?' aria-current="page"':""}>${icon}<span>${label}</span></button>`).join("")}</div>`;
321
  }
322
  function setView(view,unit){
323
  VIEW=view; draw(); root.scrollTop=0;
324
  const target=view!=="path"?null:unit?root.querySelector(`#mx-unit-${CSS.escape(unit)}`):root.querySelector(".mx-star.st-next")||root.querySelector(".mx-star.st-open");
325
- if(target) target.scrollIntoView({block:unit?"start":"center"});
 
 
326
  }
327
  function showComet(cid){
328
  const c=Sky.COMETS.find(x=>x.id===cid), have=P.comets.includes(cid);
@@ -367,7 +376,7 @@ function open(id){
367
  curQ=r.includes(P.at[id])?P.at[id]:r[0];
368
  const q=all[curQ], openEnded=q.type==="open-ended", got=solvedOf(id), gotN=r.filter(i=>got.has(i)).length;
369
  if(!cur) back=document.activeElement;
370
- cur=id; FIRST=true; REV=null;
371
  sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
372
  <div class="mx-sheet u-${s.unit}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
373
  <div class="mx-shtop"><div><span class="mx-uchip">${esc(unit.name)}</span>${s.core?' <span class="mx-core">ืœื™ื‘ื” 5 ื™ื—ืดืœ</span>':""}
@@ -385,7 +394,7 @@ function open(id){
385
  sheet.querySelector(".mx-x").focus({preventScroll:true});
386
  if(openEnded&&st!=="off")window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
387
  }
388
- function closeSheet(){ stopPhone(); dropResources(); cancelAdvance(); REV=null; if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
389
 
390
  // counts down, then opens `target` (a skill id); stops if the student moved on in the meantime
391
  function autoAdvance(id,target){
@@ -403,6 +412,7 @@ function autoAdvance(id,target){
403
 
404
  function answer(btn){
405
  if(btn.disabled) return;
 
406
  if(REV) return reviewAnswer(btn);
407
  const id=cur, m=btn.dataset.m, fb=sheet.querySelector(".mx-fb"), n=round(id).length, qi=curQ;
408
  const buttons=sheet.querySelectorAll("[data-choice]");
@@ -443,7 +453,7 @@ function answer(btn){
443
  return;
444
  }
445
  // a mistake is quiet: no sound, no vibration, and words about the mistake, not about the student
446
- Sky.wrong(P,id); FIRST=false; COMBO=0;
447
  const missed=new Set(P.missed[id]||[]); missed.add(qi); P.missed[id]=[...missed];
448
  save();
449
  const M=G.mistakes[m], to=M.back&&M.back!==id?M.back:null;
@@ -452,7 +462,8 @@ function answer(btn){
452
  if(n>1) buttons.forEach(b=>b.disabled=true); else btn.disabled=true;
453
  fb.innerHTML=`<div class="mx-bad"><small class="mx-small">ื˜ืขื•ืช ื ืคื•ืฆื”, ื•ื˜ื•ื‘ ืฉื”ื™ื ืขื•ืœื” ืขื›ืฉื™ื•</small><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
454
  ${n>1?`<span class="mx-small">ื”ืฉืืœื” ืชื—ื–ื•ืจ ืื—ืจื™ ืฉืืจ ื”ืฉืืœื•ืช, ื•ืชื•ื›ืœื• ืœื ืกื•ืช ืื•ืชื” ืฉื•ื‘.</span><button class="mx-btn solid" data-id="${id}">ืœืฉืืœื” ื”ื‘ืื”</button>`:""}
455
- ${to?`<button class="mx-btn" data-id="${to}">ืœื—ื–ืง ืงื•ื“ื ืืช: ${fmt(BY[to].short)}</button>`:n>1?"":"<span class=\"mx-small\">ืืคืฉืจ ืœื‘ื—ื•ืจ ืชืฉื•ื‘ื” ืื—ืจืช.</span>"}</div>`;
 
456
  }
457
 
458
  /* ---------- spaced review ----------
@@ -468,6 +479,49 @@ function reviewCard(){
468
  <span>${n===1?"ืžื™ื•ืžื ื•ืช ืื—ืช ืžื—ื›ื”":`${n} ืžื™ื•ืžื ื•ื™ื•ืช ืžื—ื›ื•ืช`} ืœื—ื–ืจื”. ${Math.min(n,REVIEW_SIZE)} ืฉืืœื•ืช ืžื ื•ืฉืื™ื ืฉื•ื ื™ื, ืžืขื•ืจื‘ื‘ื•ืช, ื›ื“ื™ ืฉืžื” ืฉืœืžื“ืชื ื™ื™ืฉืืจ.</span>
469
  <span class="mx-dgo">ืœื”ืชื—ื™ืœ โ€น</span></button>`;
470
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  // the oldest due skills, one per unit first, in a mixed order; a random multiple-choice question of each
472
  function startReview(){
473
  const due=dueReviews(), pick=[], units=new Set();
@@ -476,7 +530,7 @@ function startReview(){
476
  if(!pick.length) return;
477
  const items=shuffled(pick).map(id=>{ const qs=QS(BY[id]).map((q,i)=>i).filter(i=>QS(BY[id])[i].choices); return {id,qi:qs[Math.floor(Math.random()*qs.length)]}; });
478
  if(!cur) back=document.activeElement;
479
- REV={items,i:0,right:0}; openReview();
480
  }
481
  function openReview(){
482
  cancelAdvance();
@@ -508,11 +562,11 @@ function reviewAnswer(btn){
508
  rewards(id,{r,lit:false,unitWas:true});
509
  return;
510
  }
511
- Sky.wrong(P,id); save(); draw();
512
  const M=G.mistakes[m];
513
  fb.innerHTML=`<div class="mx-bad"><small class="mx-small">ื”ื ื•ืฉื: ${fmt(s.name)}</small><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
514
  <span class="mx-small">ืฉื›ื—ื” ื”ื™ื ื—ืœืง ืžื”ืœืžื™ื“ื”. ื”ื ื•ืฉื ื™ื—ื–ื•ืจ ืžื—ืจ.</span>${go}
515
- <button class="mx-btn" data-id="${id}">ืœืชืจื’ืœ ืืช ${fmt(s.short)}</button></div>`;
516
  }
517
  function reviewNext(){
518
  if(REV.i+1<REV.items.length){ REV.i++; return openReview(); }
@@ -523,9 +577,390 @@ function reviewNext(){
523
  REV=null;
524
  }
525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  function onClick(e){
527
  const t=e.target.closest("button,[data-act]"); if(!t||!root.contains(t)) return;
528
  if(t.dataset.choice!=null) return answer(t);
 
 
 
 
 
 
529
  if(t.dataset.subject) return selectSubject(t.dataset.subject);
530
  if(t.dataset.view){ closeSheet(); return setView(t.dataset.view,t.dataset.unit); }
531
  if(t.dataset.comet) return showComet(t.dataset.comet);
@@ -540,6 +975,15 @@ function onClick(e){
540
  case "subjects": return drawSubjectPicker();
541
  case "streak": return showStreak();
542
  case "review": return startReview();
 
 
 
 
 
 
 
 
 
543
  case "review-next": return REV&&reviewNext();
544
  case "cal-prev": return showStreak(-1);
545
  case "cal-next": return showStreak(1);
 
7
  shuffled each time a question is shown, and a wrong answer does not reveal the right one, so a question that comes
8
  back is solved again, not remembered. Finished skills come back in a mixed review (see "spaced review" below). Progress is kept on this device, per account (mathly.progress.v1:<email>): `done` per
9
  skill, and `solved` (indexes answered correctly), `at` (the question to show) and `set` (the round's questions, see round())
10
+ for skills with a list. Every tagged wrong answer is also kept in `slips` (see "misconception patterns" and math-sky.js),
11
+ so the mistakes that keep coming back across skills can be shown with what to practise. */
12
  (()=>{
13
  const css=document.createElement("link"); css.rel="stylesheet"; css.href=document.currentScript.src.replace("math.js","math.css");
14
+ const cssReady=new Promise(done=>{ css.onload=css.onerror=done; }); // until then #mathView is not yet the scrolling box
15
  document.head.append(css);
16
 
17
  const CFG=window.MATHLY||{}, AUTH_URL=CFG.supabaseUrl||"";
 
30
  const save=()=>{ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} };
31
 
32
  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="";
33
+ let nav=null, FIRST=true, COMBO=0, REV=null, QZ=null, UQ=null; // UQ: the unit quiz on the sheet (see unit quiz below) // QZ: the practice exam in progress (see quiz below) // the tab bar; whether the shown question is still at its first try; first-try answers in a row
34
  let picker=null, PH=null, resPicker=null, RES=null; // RES: the open lesson-resources sheet (see openResources()) // the photo file picker, and the photographed problem on the sheet (see photo())
35
  let advanceTimer=null;
36
  let phonePair=null;
 
105
  nav=document.createElement("nav"); nav.className="mx-nav"; nav.setAttribute("aria-label","ื ื™ื•ื•ื˜"); nav.hidden=true;
106
  root.append(path,sheet,picker,resPicker,nav); document.body.append(root); window.MathlySky.mount(root);
107
  root.addEventListener("click",onClick);
108
+ document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&cur) QZ?.items?quizQuit():closeSheet(); }); // an exam asks before it is left
109
  }
110
  root.hidden=false;
111
  if(!G){
 
155
  /* The math screens: home (what you gathered and your streak on top, the path below it) and the collection.
156
  The rules of the stars, the streak and the comets, and their pictures, are in math-sky.js. */
157
  const Sky=window.MathlySky;
158
+ let VIEW="path";
159
  const SOUND_ON='<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9h4l5-4v14l-5-4H3z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
160
  const SOUND_OFF='<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9h4l5-4v14l-5-4H3z"/><path d="M16 9l5 6M21 9l-5 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>';
161
  const TABS=[["path","ื‘ื™ืช",'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 2.5 11h2.7v9.5h5.3v-6h3v6h5.3V11h2.7Z"/></svg>'],
 
262
  return exam&&exam.days<=14?`<button class="mx-examsoon" data-view="sky"><b>ื”ืžื‘ื—ืŸ ื‘${esc(exam.subject)} ${inDays(exam.days)}</b><span>ืœืœื•ื— ื”ืžื‘ื—ื ื™ื โ€น</span></button>`:"";
263
  }
264
 
265
+ /* The path: unit after unit, top to bottom. Each unit is a hub in the middle with its skills orbiting it in order:
266
+ skill 1 sits at the top and the rest follow clockwise, each numbered, with arrows on the ring between them. A stretch
267
+ of the ring is lit once the skill it leaves from is done, so the lit ring leads to where to go next. The unit quiz follows the orbit. */
268
+ const ORBIT_R=34; // the ring's radius, in % of the orbit's width
269
+ const isOn=st=>st==="lit"||st==="gold"||st==="fading";
270
+ function orbitRing(sts){
271
+ const n=sts.length, a=i=>(-90+360*i/n)*Math.PI/180, pt=(t,r=ORBIT_R)=>[50+r*Math.cos(t),50+r*Math.sin(t)], f=v=>v.toFixed(2);
272
+ const gap=n>1?Math.min(.32,Math.PI/n*.55):0; // room left around each node (radians)
273
+ const arcs=sts.slice(0,-1).map((st,k)=>{
274
+ const t0=a(k)+gap, t1=a(k+1)-gap, [x0,y0]=pt(t0), [x1,y1]=pt(t1), tm=(a(k)+a(k+1))/2, [mx,my]=pt(tm), deg=tm*180/Math.PI+90;
275
+ return `<path class="${isOn(st)?"on":""}" d="M${f(x0)} ${f(y0)}A${ORBIT_R} ${ORBIT_R} 0 0 1 ${f(x1)} ${f(y1)}"/>
276
+ <path class="mx-arrow${isOn(st)?" on":""}" d="M-1.6 -1.9 1.6 0 -1.6 1.9" transform="translate(${f(mx)} ${f(my)}) rotate(${f(deg)})"/>`; }).join("");
277
+ // the closing stretch, from the last skill back to the first, stays faint: the orbit has a start and an end
278
+ const back=n>2?(()=>{ const [x0,y0]=pt(a(n-1)+gap), [x1,y1]=pt(a(n)-gap); return `<path class="mx-back" d="M${f(x0)} ${f(y0)}A${ORBIT_R} ${ORBIT_R} 0 0 1 ${f(x1)} ${f(y1)}"/>`; })():"";
279
+ return `<svg class="mx-ring" viewBox="0 0 100 100" aria-hidden="true">${back}${arcs}</svg>`;
280
+ }
281
  function pathView(){
282
  const nxt=next(), cls=MY_CLASS?` ยท ื›ื™ืชืช ${esc(MY_CLASS.name)}`:"", teach=can("students.view");
283
+ const fix=new Set(myPatterns().recurring.map(x=>x.practise)); // skills that a recurring mistake sends back to
284
  const units=mineUnits().map(u=>{
285
+ const ids=unitIds(u), act=ids.filter(live), lit=act.filter(id=>P.done[id]).length, n=ids.length, full=unitDone(u);
286
+ const sts=ids.map(id=>Sky.star(P,id,status(id),id===nxt));
287
+ const steps=ids.map((id,i)=>{ const st=sts[i], tag=st==="next"?"ืžืžืฉื™ื›ื™ื ื›ืืŸ":fix.has(id)?"ืœื—ื–ืง":st==="fading"?"ื“ื•ืขืš":"";
288
+ const t=(-90+360*i/n)*Math.PI/180, dx=Math.cos(t), dy=Math.sin(t), up=dy<-.3;
289
+ return `<div class="mx-step mx-orb u-${u.id}${up?" up":""}" style="--dx:${dx.toFixed(3)};--dy:${dy.toFixed(3)}">
290
+ <button class="mx-node mx-star st-${st}${fix.has(id)?" fix":""}" data-id="${id}" aria-label="${i+1}. ${esc(plain(BY[id].name))}: ${STATE_NAME[st]}${fix.has(id)?", ื›ื“ืื™ ืœื—ื–ืง":""}">${Sky.spark()}<span class="mx-num" aria-hidden="true">${i+1}</span>${st==="next"?`<span class="mx-perch">${Sky.owl(34)}</span>`:""}</button>
291
+ <span class="mx-lbl l-${st}${fix.has(id)?" l-fix":""}">${fmt(BY[id].short)}${tag?`<em>${tag}</em>`:""}</span></div>`; }).join("");
292
  const res=teach?` data-act="resources" data-unit="${u.id}"`:""; // a teacher opens the lesson's resources from its title
293
+ const pct=act.length?Math.round(100*lit/act.length):0;
294
+ return `<section class="mx-unit mx-orbit u-${u.id}${full?" full":""}" id="mx-unit-${u.id}">
295
+ <div class="mx-stage">${orbitRing(sts)}
296
+ <div class="mx-uhead mx-hub${teach?" is-teach":""}"${res} style="--p:${pct}">
297
+ <h2>${esc(u.name)}</h2><span class="mx-pill">${lit}/${act.length}</span>${u.when?`<span class="mx-usub">${esc(u.when)}</span>`:""}</div>
298
+ ${steps}</div>
299
+ ${teach?`<button class="mx-res-open"${res} aria-label="ืžืฉืื‘ื™ ื”ืฉื™ืขื•ืจ ${esc(u.name)}">๐Ÿ“ท ืžืฉืื‘ื™ื</button>`:""}</section>${quizStep(u)}`;
300
  }).join("");
301
+ return `${topBar()}${daily()}${reviewCard()}${patternsCard()}${examSoon()}${quizCard()}<div class="mx-pghead"><h2>ื”ืžืกืœื•ืœ ืฉืœื™</h2><p class="mx-sub">ืืœื’ื‘ืจื” ื•ื’ืื•ืžื˜ืจื™ื” ื›ื™ืชื” ื˜ืณ ยท 5 ื™ื—ืดืœ${cls}</p></div>
302
+ <div class="mx-trail">
303
  <div class="mx-start"><span class="mx-flag">${Sky.owl(40)}</span><b>ื”ืชื—ืœื”</b></div>${units}
304
  <div class="mx-finish" aria-hidden="true">${Sky.spark()}<b>ืกื•ืฃ ื”ืžืกืœื•ืœ</b></div></div>
305
+ <p class="mx-note">ื›ืœ ื”ืฉื™ืขื•ืจื™ื ืคืชื•ื—ื™ื. ื‘ื›ืœ ื™ื—ื™ื“ื” ื”ืžื™ื•ืžื ื•ื™ื•ืช ืžืกื•ื“ืจื•ืช ืกื‘ื™ื‘ื” ืœืคื™ ื”ืกื“ืจ, ืžึพ1 ืœืžืขืœื” ื•ืขื ื›ื™ื•ื•ืŸ ื”ืฉืขื•ืŸ. ื›ื•ื›ื‘ ื ื“ืœืง ื›ืฉืขื•ื ื™ื ื ื›ื•ืŸ ืขืœ ื›ืœ ื”ืฉืืœื•ืช ืฉืœ ื”ืžื™ื•ืžื ื•ืช, ื•ื”ื•ืคืš ืœื–ื”ื‘ ืื—ืจื™ ${3} ืชืฉื•ื‘ื•ืช ื ื›ื•ื ื•ืช ื‘ืจืฆืฃ (ืื• ื›ืœ ื”ืฉืืœื•ืช, ื›ืฉื™ืฉ ืคื—ื•ืช). ื›ื•ื›ื‘ ืฉืœื ืชืจื’ืœื• ${Sky.FADE_DAYS} ื™ื•ื ื“ื•ืขืš, ื•ืชืฉื•ื‘ื” ื ื›ื•ื ื” ืื—ืช ืžื—ื–ื™ืจื” ืœื• ืืช ื”ืื•ืจ. ื”ื”ืชืงื“ืžื•ืช ื ืฉืžืจืช ื‘ืžื›ืฉื™ืจ ื”ื–ื”.</p>`;
 
 
 
 
 
 
 
 
 
 
 
 
306
  }
307
 
308
  function collectionView(){
 
323
  function draw(){
324
  Sky.ensure(P);
325
  path.innerHTML=VIEW==="sky"?collectionView():pathView();
 
 
326
  nav.hidden=false;
327
  nav.innerHTML=`<div>${TABS.map(([v,label,icon])=>`<button data-view="${v}"${VIEW===v?' aria-current="page"':""}>${icon}<span>${label}</span></button>`).join("")}</div>`;
328
  }
329
  function setView(view,unit){
330
  VIEW=view; draw(); root.scrollTop=0;
331
  const target=view!=="path"?null:unit?root.querySelector(`#mx-unit-${CSS.escape(unit)}`):root.querySelector(".mx-star.st-next")||root.querySelector(".mx-star.st-open");
332
+ if(!target) return;
333
+ target.scrollIntoView({block:unit?"start":"center"});
334
+ cssReady.then(()=>{ if(VIEW===view&&target.isConnected) target.scrollIntoView({block:unit?"start":"center"}); });
335
  }
336
  function showComet(cid){
337
  const c=Sky.COMETS.find(x=>x.id===cid), have=P.comets.includes(cid);
 
376
  curQ=r.includes(P.at[id])?P.at[id]:r[0];
377
  const q=all[curQ], openEnded=q.type==="open-ended", got=solvedOf(id), gotN=r.filter(i=>got.has(i)).length;
378
  if(!cur) back=document.activeElement;
379
+ cur=id; FIRST=true; REV=null; stopQuizClock(); QZ=null; stopUnitQuiz();
380
  sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
381
  <div class="mx-sheet u-${s.unit}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
382
  <div class="mx-shtop"><div><span class="mx-uchip">${esc(unit.name)}</span>${s.core?' <span class="mx-core">ืœื™ื‘ื” 5 ื™ื—ืดืœ</span>':""}
 
394
  sheet.querySelector(".mx-x").focus({preventScroll:true});
395
  if(openEnded&&st!=="off")window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
396
  }
397
+ function closeSheet(){ stopPhone(); dropResources(); cancelAdvance(); REV=null; stopQuizClock(); QZ=null; stopUnitQuiz(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
398
 
399
  // counts down, then opens `target` (a skill id); stops if the student moved on in the meantime
400
  function autoAdvance(id,target){
 
412
 
413
  function answer(btn){
414
  if(btn.disabled) return;
415
+ if(QZ) return quizPick(btn);
416
  if(REV) return reviewAnswer(btn);
417
  const id=cur, m=btn.dataset.m, fb=sheet.querySelector(".mx-fb"), n=round(id).length, qi=curQ;
418
  const buttons=sheet.querySelectorAll("[data-choice]");
 
453
  return;
454
  }
455
  // a mistake is quiet: no sound, no vibration, and words about the mistake, not about the student
456
+ Sky.wrong(P,id); Sky.slip(P,id,qi,m,"practice"); FIRST=false; COMBO=0;
457
  const missed=new Set(P.missed[id]||[]); missed.add(qi); P.missed[id]=[...missed];
458
  save();
459
  const M=G.mistakes[m], to=M.back&&M.back!==id?M.back:null;
 
462
  if(n>1) buttons.forEach(b=>b.disabled=true); else btn.disabled=true;
463
  fb.innerHTML=`<div class="mx-bad"><small class="mx-small">ื˜ืขื•ืช ื ืคื•ืฆื”, ื•ื˜ื•ื‘ ืฉื”ื™ื ืขื•ืœื” ืขื›ืฉื™ื•</small><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
464
  ${n>1?`<span class="mx-small">ื”ืฉืืœื” ืชื—ื–ื•ืจ ืื—ืจื™ ืฉืืจ ื”ืฉืืœื•ืช, ื•ืชื•ื›ืœื• ืœื ืกื•ืช ืื•ืชื” ืฉื•ื‘.</span><button class="mx-btn solid" data-id="${id}">ืœืฉืืœื” ื”ื‘ืื”</button>`:""}
465
+ ${to?`<button class="mx-btn" data-id="${to}">ืœื—ื–ืง ืงื•ื“ื ืืช: ${fmt(BY[to].short)}</button>`:n>1?"":"<span class=\"mx-small\">ืืคืฉืจ ืœื‘ื—ื•ืจ ืชืฉื•ื‘ื” ืื—ืจืช.</span>"}
466
+ ${patternNote(m,[id,to])}</div>`;
467
  }
468
 
469
  /* ---------- spaced review ----------
 
479
  <span>${n===1?"ืžื™ื•ืžื ื•ืช ืื—ืช ืžื—ื›ื”":`${n} ืžื™ื•ืžื ื•ื™ื•ืช ืžื—ื›ื•ืช`} ืœื—ื–ืจื”. ${Math.min(n,REVIEW_SIZE)} ืฉืืœื•ืช ืžื ื•ืฉืื™ื ืฉื•ื ื™ื, ืžืขื•ืจื‘ื‘ื•ืช, ื›ื“ื™ ืฉืžื” ืฉืœืžื“ืชื ื™ื™ืฉืืจ.</span>
480
  <span class="mx-dgo">ืœื”ืชื—ื™ืœ โ€น</span></button>`;
481
  }
482
+ /* ---------- misconception patterns ----------
483
+ The mistakes that keep coming back, across skills (Sky.patterns over P.slips): a card on home, and a sheet with what
484
+ to practise for each. The words are about the mistake, never about the student; a pattern that stopped is a win. */
485
+ function myPatterns(){
486
+ const known=x=>G.mistakes[x.m]&&BY[x.practise], {recurring,stopped}=Sky.patterns(P,G.mistakes);
487
+ return {recurring:recurring.filter(known),stopped:stopped.filter(known)};
488
+ }
489
+ // after a mistake: a line when this mistake is now a recurring one, with its skill to strengthen (unless offered already)
490
+ function patternNote(m,offered=[]){
491
+ const x=myPatterns().recurring.find(x=>x.m===m); if(!x) return "";
492
+ return `<span class="mx-pattern-note">ื”ื˜ืขื•ืช ื”ื–ื• ื—ื–ืจื” ${timesWord(x.n)} ${inSkills(x.skills.length)} ื‘ื—ื•ื“ืฉ ื”ืื—ืจื•ืŸ. ื›ื“ืื™ ืœื—ื–ืง ืืช ื”ื‘ืกื™ืก ืฉืœื”, ื•ื”ื™ื ืชื™ืขืœื ื’ื ื‘ืฉืืจ ื”ื ื•ืฉืื™ื.</span>
493
+ ${offered.includes(x.practise)?"":`<button class="mx-btn" data-id="${x.practise}">ืœื—ื–ืง: ${fmt(BY[x.practise].short)}</button>`}`;
494
+ }
495
+ const inSkills=k=>k===1?"ื‘ืžื™ื•ืžื ื•ืช ืื—ืช":`ื‘ึพ${k} ืžื™ื•ืžื ื•ื™ื•ืช`;
496
+ const timesWord=n=>n===1?"ืคืขื ืื—ืช":`${n} ืคืขืžื™ื`;
497
+ const daysAgo=iso=>{ const d=Math.round((Date.parse(Sky.dayKey())-Date.parse(Sky.dayKey(new Date(iso))))/86400000); return d<=0?"ื”ื™ื•ื":d===1?"ืืชืžื•ืœ":`ืœืคื ื™ ${d} ื™ืžื™ื`; };
498
+ function patternsCard(){
499
+ const {recurring,stopped}=myPatterns(), top=recurring[0];
500
+ if(!top&&!stopped.length) return "";
501
+ const say=top?`${fmt(G.mistakes[top.m].name)}: ื—ื–ืจ ${timesWord(top.n)} ${inSkills(top.skills.length)} ืœืื—ืจื•ื ื”. ื›ื“ืื™ ืœื—ื–ืง.`
502
+ :`${fmt(G.mistakes[stopped[0].m].name)}: ื”ื“ืคื•ืก ื”ื–ื” ื›ื‘ืจ ืœื ื—ื•ื–ืจ. ื”ืชื™ืงื•ืŸ ืขื‘ื“!`;
503
+ return `<button class="mx-patterns" data-act="patterns"><b>ื“ืคื•ืกื™ื ืฉื—ื•ื–ืจื™ื</b><span>${say}</span>
504
+ <span class="mx-dgo">${top?"ืžื” ืœื—ื–ืง โ€น":"ืœืจืื•ืช โ€น"}</span></button>`;
505
+ }
506
+ function showPatterns(){
507
+ const {recurring,stopped}=myPatterns();
508
+ const row=x=>{ const M=G.mistakes[x.m];
509
+ return `<li><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
510
+ <small class="mx-small">${timesWord(x.n)} ${inSkills(x.skills.length)} ยท ืœืื—ืจื•ื ื” ${daysAgo(x.last)}</small>
511
+ <button class="mx-btn solid" data-id="${x.practise}">ืœื—ื–ืง: ${fmt(BY[x.practise].short)}</button></li>`; };
512
+ if(!cur) back=document.activeElement;
513
+ cur="patterns";
514
+ sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div><div class="mx-sheet mx-patterns-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
515
+ <div class="mx-shtop"><div><h3 id="mxTitle">ื“ืคื•ืกื™ื ืฉื—ื•ื–ืจื™ื</h3><small class="mx-src">ื˜ืขื•ื™ื•ืช ืฉื—ื–ืจื• ื‘ื—ื•ื“ืฉ ื”ืื—ืจื•ืŸ, ื’ื ื‘ื ื•ืฉืื™ื ืฉื•ื ื™ื.</small></div>
516
+ <button class="mx-x" data-act="close" aria-label="ืกื’ื™ืจื”">โœ•</button></div>
517
+ ${recurring.length?`<ol class="mx-pattern-list">${recurring.map(row).join("")}</ol>
518
+ <p class="mx-small">ื˜ืขื•ืช ืฉื—ื•ื–ืจืช ื”ื™ื ืจืžื– ืœืžื” ืฉื›ื“ืื™ ืœื—ื–ืง. ื›ืฉืžืชืจื’ืœื™ื ืืช ื”ื‘ืกื™ืก ืฉืœื”, ื”ื™ื ื ืขืœืžืช ื’ื ื‘ืฉืืจ ื”ื ื•ืฉืื™ื.</p>`
519
+ :"<p>ื›ืจื’ืข ืื™ืŸ ื˜ืขื•ืช ืฉื—ื•ื–ืจืช. ื›ืœ ื”ื›ื‘ื•ื“!</p>"}
520
+ ${stopped.length?`<h4>ื“ืคื•ืกื™ื ืฉื ืขืœืžื•</h4><ul class="mx-pattern-list gone">${stopped.map(x=>`<li><b>${fmt(G.mistakes[x.m].name)}</b>
521
+ <small class="mx-small">ืœื ื—ื–ืจ ื›ื‘ืจ ${daysAgo(x.last).replace("ืœืคื ื™ ","")}. ื”ืชื™ืงื•ืŸ ืขื‘ื“!</small></li>`).join("")}</ul>`:""}</div>`;
522
+ sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
523
+ sheet.querySelector(".mx-x").focus({preventScroll:true});
524
+ }
525
  // the oldest due skills, one per unit first, in a mixed order; a random multiple-choice question of each
526
  function startReview(){
527
  const due=dueReviews(), pick=[], units=new Set();
 
530
  if(!pick.length) return;
531
  const items=shuffled(pick).map(id=>{ const qs=QS(BY[id]).map((q,i)=>i).filter(i=>QS(BY[id])[i].choices); return {id,qi:qs[Math.floor(Math.random()*qs.length)]}; });
532
  if(!cur) back=document.activeElement;
533
+ stopQuizClock(); QZ=null; REV={items,i:0,right:0}; openReview();
534
  }
535
  function openReview(){
536
  cancelAdvance();
 
562
  rewards(id,{r,lit:false,unitWas:true});
563
  return;
564
  }
565
+ Sky.wrong(P,id); Sky.slip(P,id,it.qi,m,"review"); save(); draw();
566
  const M=G.mistakes[m];
567
  fb.innerHTML=`<div class="mx-bad"><small class="mx-small">ื”ื ื•ืฉื: ${fmt(s.name)}</small><b>${fmt(M.name)}</b><span>${fmt(M.tip)}</span>
568
  <span class="mx-small">ืฉื›ื—ื” ื”ื™ื ื—ืœืง ืžื”ืœืžื™ื“ื”. ื”ื ื•ืฉื ื™ื—ื–ื•ืจ ืžื—ืจ.</span>${go}
569
+ <button class="mx-btn" data-id="${id}">ืœืชืจื’ืœ ืืช ${fmt(s.short)}</button>${patternNote(m,[id])}</div>`;
570
  }
571
  function reviewNext(){
572
  if(REV.i+1<REV.items.length){ REV.i++; return openReview(); }
 
577
  REV=null;
578
  }
579
 
580
+ /* ---------- practice exam ----------
581
+ Like the real thing: questions from several units, mixed, the skill's name hidden, no feedback until it is handed in.
582
+ It covers what the schedule taught before the class's next exam (or before this month, without one); the student can
583
+ change the units. The report counts the answers per unit, names the three mistakes to work on with their skills, and
584
+ brings the finished skills that went wrong back to today's review. Nothing on the path is locked by it.
585
+ The results are kept in P.quizzes (newest last). */
586
+ const QUIZ_SIZE=12, QUIZ_MIN_PER_Q=3.5; // a relaxed pace: minutes per question, shown, never enforced
587
+ const SCHOOL_MONTHS=["ืกืคื˜ืžื‘ืจ","ืื•ืงื˜ื•ื‘ืจ","ื ื•ื‘ืžื‘ืจ","ื“ืฆืžื‘ืจ","ื™ื ื•ืืจ","ืคื‘ืจื•ืืจ","ืžืจืฅ","ืืคืจื™ืœ","ืžืื™","ื™ื•ื ื™","ื™ื•ืœื™","ืื•ื’ื•ืกื˜"];
588
+ const schoolMonth=d=>(d.getMonth()+4)%12; // September is 0
589
+ const skillMonth=id=>{ const m=String(BY[id].month||"").split("โ€“")[0].trim(); const i=SCHOOL_MONTHS.indexOf(m); return i<0?99:i; };
590
+ // the exam the practice prepares for: the class's next one within a month, if any
591
+ const quizExam=()=>{ const e=typeof nextExam==="function"?nextExam():null; return e&&e.days<=30?e:null; };
592
+ function quizUnits(){
593
+ const exam=quizExam(), until=exam?schoolMonth(examDate(exam.date)):schoolMonth(new Date());
594
+ const units=mineUnits().map(u=>({u,ids:unitIds(u).filter(id=>live(id)&&choiceSkill(id))})).filter(x=>x.ids.length);
595
+ let on=units.filter(x=>x.ids.some(id=>skillMonth(id)<until));
596
+ if(!on.length) on=units.filter(x=>x.ids.some(id=>skillMonth(id)<=until)); // the first month of the year
597
+ const offered=units.filter(x=>x.ids.some(id=>skillMonth(id)<=until)); // taught by then; the exam's own month may be added
598
+ return {exam,offered,on:new Set(on.map(x=>x.u.id))};
599
+ }
600
+ function quizCard(){
601
+ const {exam,on}=quizUnits(); if(!on.size) return "";
602
+ const last=(P.quizzes||[]).at(-1);
603
+ return `<button class="mx-quiz-card" data-act="quiz"><b>ืžื‘ื—ืŸ ืชืจื’ื•ืœ${exam?` ืœืงืจืืช ื”ืžื‘ื—ืŸ ${inDays(exam.days)}`:""}</b>
604
+ <span>${QUIZ_SIZE} ืฉืืœื•ืช ืžึพ${on.size} ื™ื—ื™ื“ื•ืช, ืžืขื•ืจื‘ื‘ื•ืช, ื›ืžื• ื‘ืžื‘ื—ืŸ. ื”ืžืฉื•ื‘ ืžื’ื™ืข ื‘ืกื•ืฃ, ืขื ืชื•ื›ื ื™ืช: ืžื” ืœื—ื–ืง ืขื“ ื”ืžื‘ื—ืŸ.</span>
605
+ ${last?`<span>ื‘ืคืขื ื”ืงื•ื“ืžืช: ${last.right} ืžืชื•ืš ${last.n}</span>`:""}<span class="mx-dgo">ืœื”ืชื—ื™ืœ โ€น</span></button>`;
606
+ }
607
+ function quizSetup(){
608
+ const {exam,offered,on}=quizUnits();
609
+ if(!cur) back=document.activeElement;
610
+ cur="quiz"; QZ={setup:true,on,offered};
611
+ sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
612
+ <div class="mx-sheet mx-quiz-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
613
+ <div class="mx-shtop"><div><span class="mx-uchip">ืžื‘ื—ืŸ ืชืจื’ื•ืœ</span><h3 id="mxTitle">${exam?`ืœืงืจืืช ื”ืžื‘ื—ืŸ ื‘${esc(exam.subject)} ${inDays(exam.days)}`:"ืžื‘ื—ืŸ ืชืจื’ื•ืœ"}</h3>
614
+ <small class="mx-src">${QUIZ_SIZE} ืฉืืœื•ืช ยท ื–ืžืŸ ืžื•ืžืœืฅ: ื›ึพ${Math.round(QUIZ_SIZE*QUIZ_MIN_PER_Q/5)*5} ื“ืงื•ืช, ื‘ืœื™ ืœื—ืฅ: ื”ืฉืขื•ืŸ ืจืง ืžืจืื”.</small></div>
615
+ <button class="mx-x" data-act="close" aria-label="ืกื’ื™ืจื”">โœ•</button></div>
616
+ <p>ืื™ืœื• ื™ื—ื™ื“ื•ืช ื‘ืžื‘ื—ืŸ? ืกื™ืžื ื• ืืช ืžื” ืฉื ืœืžื“ ืขื“ ${exam?"ื”ืžื‘ื—ืŸ":"ืขื›ืฉื™ื•"}. ืืคืฉืจ ืœืฉื ื•ืช.</p>
617
+ <div class="mx-quiz-units">${offered.map(({u})=>`<button class="mx-btn" data-quiz-unit="${u.id}" aria-pressed="${on.has(u.id)}">${esc(u.name)}</button>`).join("")}</div>
618
+ <p class="mx-small">ื”ืฉืืœื•ืช ืžืขื•ืจื‘ื‘ื•ืช ื•ืฉื ื”ื ื•ืฉื ืœื ืžื•ืคื™ืข: ืœื–ื”ื•ืช ืื™ื–ื• ื“ืจืš ืžืชืื™ืžื” ื–ื” ื—ืœืง ืžื”ืžื‘ื—ืŸ. ืื™ืŸ ืžืฉื•ื‘ ื‘ืืžืฆืข, ื›ืžื• ื‘ืžื‘ื—ืŸ ืืžื™ืชื™.</p>
619
+ <button class="mx-btn solid" data-act="quiz-start"${on.size?"":" disabled"}>ืœื”ืชื—ื™ืœ</button></div>`;
620
+ sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
621
+ }
622
+ function quizToggle(uid){
623
+ if(!QZ?.setup) return;
624
+ QZ.on.has(uid)?QZ.on.delete(uid):QZ.on.add(uid);
625
+ sheet.querySelector(`[data-quiz-unit="${CSS.escape(uid)}"]`).setAttribute("aria-pressed",QZ.on.has(uid));
626
+ sheet.querySelector("[data-act=quiz-start]").disabled=!QZ.on.size;
627
+ }
628
+ // questions from the chosen units in proportion to their skills: the units take turns, each turn a skill not used yet
629
+ function quizStart(){
630
+ const units=QZ.offered.filter(x=>QZ.on.has(x.u.id)).map(x=>({u:x.u,ids:shuffled(x.ids)}));
631
+ const total=units.reduce((n,x)=>n+x.ids.length,0), items=[], used=new Set();
632
+ const share=units.map(x=>({...x,want:Math.max(1,Math.round(QUIZ_SIZE*x.ids.length/total)),k:0}));
633
+ for(let turn=0;items.length<QUIZ_SIZE&&turn<QUIZ_SIZE*4;turn++){
634
+ for(const x of share){
635
+ if(items.length>=QUIZ_SIZE||x.k>=x.want) continue;
636
+ const id=x.ids[x.k%x.ids.length], qs=QS(BY[id]).map((q,i)=>i).filter(i=>QS(BY[id])[i].choices&&!used.has(`${id}:${i}`));
637
+ if(!qs.length){ x.k++; continue; }
638
+ const qi=qs[Math.floor(Math.random()*qs.length)]; used.add(`${id}:${qi}`); items.push({id,qi,pick:null}); x.k++;
639
+ }
640
+ if(share.every(x=>x.k>=x.want)) share.forEach(x=>x.want++); // rounding left room: one more for each, in turn
641
+ }
642
+ QZ={items:shuffled(items),i:0,started:Date.now(),units:units.map(x=>x.u.id)};
643
+ quizShow();
644
+ QZ.clock=setInterval(quizTick,1000);
645
+ }
646
+ function stopQuizClock(){ if(QZ?.clock) clearInterval(QZ.clock); }
647
+ const mmss=ms=>{ const s=Math.floor(ms/1000); return `${Math.floor(s/60)}:${String(s%60).padStart(2,"0")}`; };
648
+ function quizTick(){ const el=sheet.querySelector(".mx-quiz-clock"); if(el&&QZ?.started) el.textContent=mmss(Date.now()-QZ.started); }
649
+ function quizShow(){
650
+ const {items,i}=QZ, it=items[i], q=QS(BY[it.id])[it.qi], last=i===items.length-1;
651
+ if(!it.order) it.order=shuffled(q.choices.map((_,k)=>k)); // the same order when coming back to a question
652
+ sheet.innerHTML=`<div class="mx-scrim"></div>
653
+ <div class="mx-sheet mx-quiz-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
654
+ <div class="mx-shtop"><div><span class="mx-uchip">ืžื‘ื—ืŸ ืชืจื’ื•ืœ</span><h3 id="mxTitle">ืฉืืœื” ${i+1} ืžืชื•ืš ${items.length}</h3>
655
+ <small class="mx-src">โฑ <span class="mx-quiz-clock">${mmss(Date.now()-QZ.started)}</span></small></div>
656
+ <button class="mx-x" data-act="quiz-quit" aria-label="ื™ืฆื™ืื” ืžื”ืžื‘ื—ืŸ">โœ•</button></div>
657
+ <div class="mx-quiz-dots">${items.map((x,k)=>`<button data-quiz-go="${k}" class="${x.pick!=null?"done":""}${k===i?" now":""}" aria-label="ืฉืืœื” ${k+1}${x.pick!=null?", ื ืขื ืชื”":""}">${k+1}</button>`).join("")}</div>
658
+ <div class="mx-q"><p>${fmt(q.question)}</p>${figures(q)}
659
+ <div class="mx-choices">${it.order.map(k=>{ const c=q.choices[k]; return `<button data-choice="${k}" data-m="${c.correct?"":esc(c.mistake)}"${c.correct?" data-right":""} aria-pressed="${it.pick===k}">${fmt(c.text)}</button>`; }).join("")}</div></div>
660
+ <div class="mx-quiz-nav"><button class="mx-btn" data-act="quiz-prev"${i?"":" disabled"}>ื”ืงื•ื“ืžืช</button>
661
+ ${last?`<button class="mx-btn solid" data-act="quiz-submit">ื”ื’ืฉื”</button>`:`<button class="mx-btn solid" data-act="quiz-next">ื”ื‘ืื”</button>`}</div></div>`;
662
+ sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
663
+ }
664
+ // a choice is only marked; it can be changed until the exam is handed in
665
+ function quizPick(btn){
666
+ if(!QZ?.items) return;
667
+ const it=QZ.items[QZ.i]; it.pick=+btn.dataset.choice;
668
+ sheet.querySelectorAll("[data-choice]").forEach(b=>b.setAttribute("aria-pressed",b===btn));
669
+ sheet.querySelector(`[data-quiz-go="${QZ.i}"]`).classList.add("done");
670
+ Sky.sound.tap();
671
+ }
672
+ function quizGo(k){ if(!QZ?.items) return; QZ.i=Math.max(0,Math.min(QZ.items.length-1,k)); quizShow(); }
673
+ function quizSubmit(){
674
+ const left=QZ.items.filter(x=>x.pick==null).length;
675
+ if(left&&!QZ.sure){
676
+ QZ.sure=true;
677
+ const nav=sheet.querySelector(".mx-quiz-nav");
678
+ nav.insertAdjacentHTML("beforebegin",`<p class="mx-small mx-quiz-warn">${left===1?"ืฉืืœื” ืื—ืช ืœื ื ืขื ืชื”":`${left} ืฉืืœื•ืช ืœื ื ืขื ื•`}. ืืคืฉืจ ืœื—ื–ื•ืจ ืืœื™ื”ืŸ ื‘ืžืกืคืจื™ื ืœืžืขืœื”, ืื• ืœืœื—ื•ืฅ ืฉื•ื‘ ืขืœ ื”ื’ืฉื”.</p>`);
679
+ return;
680
+ }
681
+ stopQuizClock();
682
+ const items=QZ.items, ms=Date.now()-QZ.started, now=new Date().toISOString();
683
+ const res=items.map(x=>{ const q=QS(BY[x.id])[x.qi], c=x.pick==null?null:q.choices[x.pick]; return {...x,q,ok:!!c?.correct,mistake:c&&!c.correct?c.mistake:null}; });
684
+ const right=res.filter(r=>r.ok).length;
685
+ // answering counts like practice: right answers go to today's goal, and a finished skill that went wrong is reviewed today
686
+ for(const r of res){
687
+ if(r.ok){ Sky.ensure(P); const k=Sky.dayKey(); P.days[k]=(P.days[k]||0)+1; }
688
+ else if(P.done[r.id]){ Sky.ensure(P); P.review[r.id]={box:0,due:Sky.dayKey()}; }
689
+ if(r.mistake) Sky.slip(P,r.id,r.qi,r.mistake,"exam",new Date(now));
690
+ }
691
+ const mistakes={}; res.filter(r=>r.mistake).forEach(r=>{ (mistakes[r.mistake]??={n:0,ids:new Set()}).n++; mistakes[r.mistake].ids.add(r.id); });
692
+ const skipped=res.filter(r=>r.pick==null).map(r=>r.id);
693
+ const top=Object.entries(mistakes).sort((a,b)=>b[1].n-a[1].n).slice(0,3);
694
+ const weak=[...new Set([...top.flatMap(([,m])=>[...m.ids]),...skipped])];
695
+ (P.quizzes=P.quizzes||[]).push({at:now,n:items.length,right,ms,units:QZ.units,weak}); P.quizzes=P.quizzes.slice(-20);
696
+ save(); draw();
697
+ const perUnit=QZ.units.map(uid=>{ const u=G.units.find(x=>x.id===uid), rs=res.filter(r=>BY[r.id].unit===uid); return {u,n:rs.length,ok:rs.filter(r=>r.ok).length}; }).filter(x=>x.n);
698
+ const plan=top.length?top.map(([k,m])=>{ const M=G.mistakes[k]; return `<li><b>${fmt(M.name)}</b>${m.n>1?` <small>(${m.n} ืคืขืžื™ื)</small>`:""}<span>${fmt(M.tip)}</span>
699
+ <span class="mx-chips">${[...m.ids].map(id=>`<button class="mx-btn" data-id="${id}">ืœืชืจื’ืœ: ${fmt(BY[id].short)}</button>`).join("")}</span></li>`; }).join(""):"";
700
+ QZ={done:true};
701
+ sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
702
+ <div class="mx-sheet mx-quiz-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
703
+ <div class="mx-shtop"><div><span class="mx-uchip">ืžื‘ื—ืŸ ืชืจื’ื•ืœ</span><h3 id="mxTitle">${right} ืžืชื•ืš ${items.length} ื ื›ื•ื ื•ืช</h3>
704
+ <small class="mx-src">โฑ ${mmss(ms)}${skipped.length?` ยท ${skipped.length} ื‘ืœื™ ืชืฉื•ื‘ื”`:""}</small></div>
705
+ <button class="mx-x" data-act="close" aria-label="ืกื’ื™ืจื”">โœ•</button></div>
706
+ <div class="mx-quiz-units-score">${perUnit.map(x=>`<div><span>${esc(x.u.name)}</span><b>${x.ok}/${x.n}</b><span class="mx-meter" aria-hidden="true"><i style="width:${Math.round(100*x.ok/x.n)}%"></i></span></div>`).join("")}</div>
707
+ ${plan?`<h4>${top.length===1?"ื“ื‘ืจ ืื—ื“":top.length===2?"ืฉื ื™ ื“ื‘ืจื™ื":"ืฉืœื•ืฉื” ื“ื‘ืจื™ื"} ืœื—ื–ืง ืขื“ ื”ืžื‘ื—ืŸ</h4><ol class="mx-quiz-plan">${plan}</ol>
708
+ <p class="mx-small">ืžื™ื•ืžื ื•ื™ื•ืช ืฉื›ื‘ืจ ืกื™ื™ืžืชื ื•ื˜ืขื™ืชื ื‘ื”ืŸ ื—ื•ื–ืจื•ืช ื”ื™ื•ื ืœื—ื–ืจื” ื”ืžืขื•ืจื‘ืช.</p>`
709
+ :right===items.length?"<p>ื”ื›ื•ืœ ื ื›ื•ืŸ. ืžืžืฉื™ื›ื™ื ืœืชืจื’ืœ ืงืฆืช ื›ืœ ื™ื•ื, ื•ื–ื” ื™ื™ืฉืืจ.</p>":""}
710
+ <details class="mx-quiz-review"><summary>ื›ืœ ื”ืฉืืœื•ืช ื•ื”ืชืฉื•ื‘ื•ืช</summary><ol>${res.map(r=>{ const c=r.q.choices.find(c=>c.correct), mine=r.pick==null?null:r.q.choices[r.pick];
711
+ return `<li class="${r.ok?"ok":"bad"}"><span>${fmt(r.q.question)}</span><small>${esc(plain(BY[r.id].short))}</small>
712
+ ${r.ok?`<span>โœ“ ${fmt(c.text)}</span>`:`<span>${mine?`ืขื ื™ืช: ${fmt(mine.text)}`:"ื‘ืœื™ ืชืฉื•ื‘ื”"} ยท ื ื›ื•ืŸ: ${fmt(c.text)}</span>`}</li>`; }).join("")}</ol></details>
713
+ <div class="mx-quiz-nav"><button class="mx-btn" data-act="quiz">ืžื‘ื—ืŸ ืชืจื’ื•ืœ ื—ื“ืฉ</button><button class="mx-btn solid" data-act="close">ื—ื–ืจื” ืœืžืกืœื•ืœ</button></div></div>`;
714
+ sheet.querySelector(".mx-x").focus({preventScroll:true});
715
+ if(right/items.length>=.8) Sky.sound.goal();
716
+ }
717
+ function quizQuit(){
718
+ if(!QZ?.items) return closeSheet();
719
+ if(QZ.items.every(x=>x.pick==null)||QZ.quitting) return closeSheet();
720
+ QZ.quitting=true;
721
+ sheet.querySelector(".mx-quiz-nav").insertAdjacentHTML("beforebegin",'<p class="mx-small mx-quiz-warn">ืœืฆืืช? ื”ืชืฉื•ื‘ื•ืช ื‘ืžื‘ื—ืŸ ื”ื–ื” ืœื ื™ื™ืฉืžืจื•. ืœื—ืฆื• ืฉื•ื‘ ืขืœ โœ• ื›ื“ื™ ืœืฆืืช.</p>');
722
+ }
723
+
724
+ /* ---------- unit quiz ----------
725
+ A unit can end with a summarizing quiz (unit.quiz, app/math_skills.py): open-ended questions with points, and a time
726
+ limit. It is shown like a paper quiz: all the questions at once, a clock counting down, nothing checked until it is
727
+ handed in. The student writes on paper and photographs each answer (on this device, or from the phone on the same
728
+ account); when the time is up there are UQ_PHOTO_MIN more minutes to photograph, then it is handed in by itself.
729
+ After handing in, each answer is checked (the server does not check quiz answers on upload), and the report shows
730
+ the points per question with feedback and a solution. The sitting is kept in P.uq[unit]: {sitting:{started,
731
+ attempts, submitted}, history:[{at, score, graded}]} so a reload continues it with the clock still running.
732
+ The time limit is kept by this page; the server keeps the answers and their checks. */
733
+ const UQ_PHOTO_MIN=10, UQ_BASE="/api/math/answer-attempts";
734
+ const uqState=uid=>((P.uq||={})[uid]||={history:[]});
735
+ const uqReady=u=>!!(u.quiz&&!u.quiz.locked&&CFG.answerScans&&CFG.userId);
736
+ const uqEnds=(u,sit)=>sit.started+u.quiz.minutes*60000;
737
+ function quizStep(u){
738
+ if(!u.quiz) return "";
739
+ const st=uqState(u.id), last=st.history.at(-1), going=st.sitting&&!st.sitting.submitted;
740
+ const tag=going?"ื‘ืชื”ืœื™ืš":last?.score!=null?`${last.score}/100`:last?"ื”ื•ื’ืฉ":"";
741
+ return `<div class="mx-step mx-uquiz-step u-${u.id}"><button class="mx-node mx-uquiz${last?" done":""}" data-uquiz="${u.id}" aria-label="ื‘ื•ื—ืŸ ืžืกื›ื: ${esc(u.name)}${tag?`, ${tag}`:""}">๐Ÿ“</button>
742
+ <span class="mx-lbl">ื‘ื•ื—ืŸ ืžืกื›ื${tag?`<em>${tag}</em>`:""}</span></div>`;
743
+ }
744
+ async function uqJson(url,options={}){
745
+ const r=await api(url,{cache:"no-store",...options}), d=await r.json().catch(()=>({}));
746
+ if(!r.ok){ const e=Error(UQ_ERRORS[d.detail]||"ื”ื—ื™ื‘ื•ืจ ื ืงื˜ืข. ื ืกื• ืฉื•ื‘."); e.code=d.detail; e.status=r.status; throw e; }
747
+ return d;
748
+ }
749
+ const uqPost=(url,body={})=>uqJson(url,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
750
+ const UQ_ERRORS={question_changed:"ื”ื‘ื•ื—ืŸ ืขื•ื“ื›ืŸ. ืจืขื ื ื• ืืช ื”ืขืžื•ื“ ื•ื”ืชื—ื™ืœื• ืžื—ื“ืฉ.",image_too_large:"ื”ืชืžื•ื ื” ื’ื“ื•ืœื” ืžื“ื™. ืฆืœืžื• ืฉื•ื‘.",invalid_image:"ืœื ื ื™ืชืŸ ืœืงืจื•ื ืืช ื”ืชืžื•ื ื”. ืฆืœืžื• ืฉื•ื‘.",
751
+ unsupported_image:"ื‘ื—ืจื• ืชืžื•ื ืช JPEG, PNG ืื• WebP.",answer_storage_unavailable:"ืฉืžื™ืจืช ืชืฉื•ื‘ื•ืช ืื™ื ื” ื–ืžื™ื ื” ื›ืจื’ืข.",grading_unavailable:"ื”ื‘ื“ื™ืงื” ื”ืื•ื˜ื•ืžื˜ื™ืช ืื™ื ื” ื–ืžื™ื ื” ื›ืจื’ืข.",
752
+ grading_failed:"ื”ื‘ื“ื™ืงื” ื ื›ืฉืœื”. ื”ืชืžื•ื ื” ื ืฉืžืจื”; ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘.",grading_limit:"ื”ื’ืขืชื ืœืžื›ืกืช ื”ื‘ื“ื™ืงื•ืช ื”ื™ื•ืžื™ืช. ื ืกื• ืฉื•ื‘ ืžื—ืจ.",grading_busy:"ื›ื‘ืจ ืžืชื‘ืฆืขื•ืช ื‘ื“ื™ืงื•ืช. ื ืกื• ืฉื•ื‘ ื‘ืขื•ื“ ืจื’ืข.",
753
+ upload_in_progress:"ื”ืขืœืื” ื›ื‘ืจ ืžืชื‘ืฆืขืช. ืจื’ืขโ€ฆ",open_ended_locked:"ื‘ื•ื—ื ื™ื ืคืชื•ื—ื™ื ืกื’ื•ืจื™ื ื›ืจื’ืข ื‘ืืชืจ.",sign_in_required:"ื™ืฉ ืœื”ื™ื›ื ืก ืœื—ืฉื‘ื•ืŸ."};
754
+ function stopUnitQuiz(){ if(UQ){ clearInterval(UQ.clock); clearTimeout(UQ.poll); Object.values(UQ.images||{}).forEach(URL.revokeObjectURL); } UQ=null; }
755
+ // the intro: what the quiz is and how it works, the last results, and start / continue
756
+ function uqIntro(uid){
757
+ const u=G.units.find(x=>x.id===uid), q=u.quiz, st=uqState(uid), sit=st.sitting, going=sit&&!sit.submitted;
758
+ if(going) return uqOpen(uid);
759
+ if(!cur) back=document.activeElement;
760
+ stopUnitQuiz(); cur="uquiz"; UQ={uid};
761
+ const ready=uqReady(u), n=q.questions.length;
762
+ sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
763
+ <div class="mx-sheet mx-uq-sheet u-${uid}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
764
+ <div class="mx-shtop"><div><span class="mx-uchip">${esc(u.name)}</span><h3 id="mxTitle">ื‘ื•ื—ืŸ ืžืกื›ื</h3>
765
+ <small class="mx-src">${n} ืฉืืœื•ืช ืคืชื•ื—ื•ืช ยท ${q.minutes} ื“ืงื•ืช ยท 100 ื ืงื•ื“ื•ืช</small></div>
766
+ <button class="mx-x" data-act="close" aria-label="ืกื’ื™ืจื”">โœ•</button></div>
767
+ <ul class="mx-uq-rules">
768
+ <li>ื›ืžื• ื‘ื•ื—ืŸ ื‘ื›ื™ืชื”: ื›ืœ ื”ืฉืืœื•ืช ืžื•ืคื™ืขื•ืช ื™ื—ื“, ื•ื”ืฉืขื•ืŸ ืกื•ืคืจ ืœืื—ื•ืจ ืžืจื’ืข ื”ื”ืชื—ืœื”.</li>
769
+ <li>ื›ืชื‘ื• ืืช ื”ืคืชืจื•ื ื•ืช ืขืœ ื“ืฃ, ื›ืœ ืฉืืœื” ื‘ื ืคืจื“, ืขื ื“ืจืš ืคืชืจื•ืŸ ืžืœืื” ื•ื ื™ืžื•ืงื™ื.</li>
770
+ <li>ืฆืœืžื• ืืช ื”ืชืฉื•ื‘ื” ืœื›ืœ ืฉืืœื”. ื›ืฉื”ื–ืžืŸ ื ื’ืžืจ ื™ืฉ ืขื•ื“ ${UQ_PHOTO_MIN} ื“ืงื•ืช ืœืฆืœื ื•ืœื”ื’ื™ืฉ.</li>
771
+ <li>ื”ื‘ื“ื™ืงื”, ื”ืฆื™ื•ืŸ ื•ื”ืคืชืจื•ื ื•ืช ืžื’ื™ืขื™ื ืจืง ืื—ืจื™ ื”ื”ื’ืฉื”.</li>
772
+ <li>ืืคืฉืจ ืœืกื’ื•ืจ ื•ืœื—ื–ื•ืจ, ืื‘ืœ ื”ืฉืขื•ืŸ ืžืžืฉื™ืš ืœืจื•ืฅ.</li></ul>
773
+ ${st.history.length?`<div class="mx-uq-history"><h4>ื”ื’ืฉื•ืช ืงื•ื“ืžื•ืช</h4>${st.history.slice().reverse().map((h,k)=>{ const i=st.history.length-1-k;
774
+ return `<button class="mx-btn" data-uq-result="${uid}:${i}">${new Date(h.at).toLocaleDateString("he-IL")} ยท ${h.score==null?"ืžืžืชื™ืŸ ืœื‘ื“ื™ืงื”":`${h.score}/100`}</button>`; }).join("")}</div>`:""}
775
+ ${ready?`<button class="mx-btn solid" data-act="uq-start">ื”ืชื—ืœืช ื”ื‘ื•ื—ืŸ</button>`
776
+ :`<p class="mx-note">${!CFG.userId?"ื”ื‘ื•ื—ืŸ ื–ืžื™ืŸ ืื—ืจื™ ื›ื ื™ืกื” ืœื—ืฉื‘ื•ืŸ.":"ื‘ื•ื—ื ื™ื ืขื ืชืฉื•ื‘ื” ืžืฆื•ืœืžืช ื•ื‘ื“ื™ืงื” ืื•ื˜ื•ืžื˜ื™ืช ืื™ื ื ื–ืžื™ื ื™ื ื›ืจื’ืข ื‘ืืชืจ."}</p>`}</div>`;
777
+ sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
778
+ }
779
+ // a new sitting: one fresh answer attempt per question
780
+ async function uqStart(){
781
+ const uid=UQ.uid, u=G.units.find(x=>x.id===uid), btn=sheet.querySelector("[data-act=uq-start]");
782
+ btn.disabled=true; btn.textContent="ืžื›ื™ื ื™ื ืืช ื”ื‘ื•ื—ืŸโ€ฆ";
783
+ try{
784
+ const attempts=[];
785
+ for(const [index,q] of u.quiz.questions.entries())
786
+ attempts.push((await uqPost("/api/math/quiz-attempts",{unit:uid,index,question_version:q.question_version,new:true})).attempt.id);
787
+ uqState(uid).sitting={started:Date.now(),attempts,submitted:null}; save(); draw();
788
+ uqOpen(uid);
789
+ }catch(e){ btn.disabled=false; btn.textContent="ื”ืชื—ืœืช ื”ื‘ื•ื—ืŸ"; btn.insertAdjacentHTML("afterend",`<p class="mx-small mx-quiz-warn">${esc(e.message)}</p>`); }
790
+ }
791
+ // the quiz paper
792
+ function uqOpen(uid){
793
+ const u=G.units.find(x=>x.id===uid), sit=uqState(uid).sitting;
794
+ if(!cur) back=document.activeElement;
795
+ stopUnitQuiz(); cur="uquiz"; UQ={uid,res:{},images:{},busy:{},msg:{}};
796
+ sheet.innerHTML=`<div class="mx-scrim"></div>
797
+ <div class="mx-sheet mx-uq-sheet mx-uq-paper u-${uid}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
798
+ <div class="mx-uq-head"><div><span class="mx-uchip">${esc(u.name)}</span><h3 id="mxTitle">ื‘ื•ื—ืŸ ืžืกื›ื</h3></div>
799
+ <div class="mx-uq-clock" role="timer" aria-live="off"></div>
800
+ <button class="mx-x" data-act="close" aria-label="ื™ืฆื™ืื” (ื”ืฉืขื•ืŸ ืžืžืฉื™ืš)">โœ•</button></div>
801
+ <p class="mx-uq-banner" aria-live="polite"></p>
802
+ <ol class="mx-uq-qs">${u.quiz.questions.map((q,i)=>`<li class="mx-uq-q" id="uq-q${i}">
803
+ <div class="mx-uq-qhead"><b>ืฉืืœื” ${i+1}</b><span>${q.points} ื ืงืณ</span></div>
804
+ <p class="mx-uq-text">${fmt(q.question).replace(/\n/g,"<br>")}</p>${figures(q)}
805
+ <div class="mx-uq-answer" data-uq-i="${i}"></div></li>`).join("")}</ol>
806
+ <div class="mx-quiz-nav"><span class="mx-small" data-uq-count></span><button class="mx-btn solid" data-act="uq-submit">ื”ื’ืฉืช ื”ื‘ื•ื—ืŸ</button></div>
807
+ <input type="file" accept="image/*" capture="environment" hidden data-uq-file></div>`;
808
+ sheet.hidden=false; sheet.querySelector(".mx-sheet").scrollTop=0;
809
+ sheet.querySelector("[data-uq-file]").onchange=e=>{ const f=e.target.files[0]; e.target.value=""; if(f&&UQ?.pick!=null) uqUpload(UQ.pick,f); };
810
+ uqTick(); UQ.clock=setInterval(uqTick,1000);
811
+ sit.attempts.forEach((_,i)=>uqRefresh(i));
812
+ }
813
+ function uqTick(){
814
+ if(!UQ?.uid) return;
815
+ const u=G.units.find(x=>x.id===UQ.uid), sit=uqState(UQ.uid).sitting; if(!sit||sit.submitted) return;
816
+ const left=uqEnds(u,sit)-Date.now(), photo=left+UQ_PHOTO_MIN*60000;
817
+ const clock=sheet.querySelector(".mx-uq-clock"), banner=sheet.querySelector(".mx-uq-banner"); if(!clock) return;
818
+ if(photo<=0) return uqSubmit(true);
819
+ clock.textContent=left>0?mmss(left):mmss(photo);
820
+ clock.classList.toggle("low",left>0&&left<5*60000); clock.classList.toggle("over",left<=0);
821
+ const say=left>0?(left<5*60000?"ื ืฉืืจื• ืคื—ื•ืช ืžึพ5 ื“ืงื•ืช. ื–ื” ื–ืžืŸ ื˜ื•ื‘ ืœื‘ื“ื•ืง ื•ืœืฆืœื.":""):`ื”ื–ืžืŸ ืœื›ืชื™ื‘ื” ื ื’ืžืจ. ื ืฉืืจื• ${mmss(photo)} ืœืฆื™ืœื•ื ื•ืœื”ื’ืฉื”, ื‘ืœื™ ืœื”ื•ืกื™ืฃ ืœืคืชืจื•ื ื•ืช.`;
822
+ if(banner.textContent!==say) banner.textContent=say;
823
+ }
824
+ // one question's answer box: the photo if there is one, and the ways to add or replace it
825
+ async function uqRefresh(i){
826
+ const sit=uqState(UQ.uid).sitting, aid=sit.attempts[i], uid=UQ.uid;
827
+ try{
828
+ const r=await uqJson(`${UQ_BASE}/${aid}`); if(UQ?.uid!==uid) return;
829
+ UQ.res[i]=r;
830
+ if(r.attachment&&!UQ.images[i]){ const b=await api(`${UQ_BASE}/${aid}/image`,{cache:"no-store"}); if(b.ok&&UQ?.uid===uid) UQ.images[i]=URL.createObjectURL(await b.blob()); }
831
+ }catch(e){ if(UQ?.uid===uid) UQ.msg[i]=e.message; }
832
+ if(UQ?.uid===uid) uqDrawAnswer(i);
833
+ // waiting for the phone: look again soon
834
+ if(UQ?.uid===uid&&!UQ.res[i]?.attachment&&["waiting","confirmed","uploading"].includes(UQ.res[i]?.scan?.state)){ clearTimeout(UQ.poll); UQ.poll=setTimeout(()=>sit.attempts.forEach((_,k)=>uqRefresh(k)),4000); }
835
+ }
836
+ function uqDrawAnswer(i){
837
+ const box=sheet.querySelector(`[data-uq-i="${i}"]`); if(!box) return;
838
+ const r=UQ.res[i], has=!!r?.attachment, busy=UQ.busy[i], st=r?.scan?.state, phone=["waiting","confirmed"].includes(st)&&!has;
839
+ const touch=matchMedia("(max-width: 700px)").matches||navigator.maxTouchPoints>0;
840
+ // on a computer the phone comes first: it is how most answers on paper get here
841
+ const phoneBtn=touch?"":`<button class="mx-btn${has?"":" solid"} mx-phone-cta" data-uq-phone="${i}"${busy||st==="uploading"?" disabled":""}><span aria-hidden="true">๐Ÿ“ฑ</span> ${phone?"ื”ืฆื’ืช ื”ืžืกืคืจ ืœื˜ืœืคื•ืŸ":has?"ืฆื™ืœื•ื ืžื—ื“ืฉ ื‘ื˜ืœืคื•ืŸ":"ืฆื™ืœื•ื ื‘ื˜ืœืคื•ืŸ"}</button>`;
842
+ box.innerHTML=`${has?`<img class="mx-uq-img" alt="ื”ืชืฉื•ื‘ื” ื”ืžืฆื•ืœืžืช ืœืฉืืœื” ${i+1}" src="${UQ.images[i]||""}">`:""}
843
+ <p class="mx-small" role="status">${busy?esc(busy):UQ.msg[i]?esc(UQ.msg[i]):has?"โœ“ ื”ืชืฉื•ื‘ื” ื ืฉืžืจื”":phone?"ืžืžืชื™ื ื™ื ืœืฆื™ืœื•ื ืžื”ื˜ืœืคื•ืŸ":st==="declined"?"ื”ื‘ืงืฉื” ื ื“ื—ืชื” ื‘ื˜ืœืคื•ืŸ":"ืขื•ื“ ืœื ืฆื•ืœืžื” ืชืฉื•ื‘ื”"}</p>
844
+ <span class="mx-chips">${phoneBtn}<button class="mx-btn${has||!touch?"":" solid"}" data-uq-photo="${i}"${busy?" disabled":""}>${has&&touch?"ืฆื™ืœื•ื ืžื—ื“ืฉ":touch?"ืฆื™ืœื•ื ื”ืชืฉื•ื‘ื”":"ื”ืขืœืืช ืชืžื•ื ื” ืžื”ืžื—ืฉื‘"}</button></span>`;
845
+ const done=Object.values(UQ.res).filter(x=>x?.attachment).length, n=uqState(UQ.uid).sitting.attempts.length;
846
+ const c=sheet.querySelector("[data-uq-count]"); if(c) c.textContent=`ืฆื•ืœืžื• ${done} ืžืชื•ืš ${n} ืชืฉื•ื‘ื•ืช`;
847
+ }
848
+ // a photo replaces an answer by a fresh attempt for that question (an attempt keeps the one photo it received)
849
+ async function uqFresh(i){
850
+ const u=G.units.find(x=>x.id===UQ.uid), q=u.quiz.questions[i], sit=uqState(UQ.uid).sitting;
851
+ if(!UQ.res[i]?.attachment) return sit.attempts[i];
852
+ const r=await uqPost("/api/math/quiz-attempts",{unit:UQ.uid,index:i,question_version:q.question_version,new:true});
853
+ sit.attempts[i]=r.attempt.id; save();
854
+ if(UQ.images[i]){ URL.revokeObjectURL(UQ.images[i]); delete UQ.images[i]; }
855
+ UQ.res[i]=r; return r.attempt.id;
856
+ }
857
+ // this device: request, confirm, lease and upload in a row (the same steps the phone takes)
858
+ async function uqUpload(i,file){
859
+ const uid=UQ.uid; UQ.busy[i]="ืžืขืœื™ื ืืช ื”ืชืžื•ื ื”โ€ฆ"; UQ.msg[i]=""; uqDrawAnswer(i);
860
+ try{
861
+ const photo=await window.MathlyPhoto.prepare(file);
862
+ if(photo.size>5000000) throw Error(UQ_ERRORS.image_too_large);
863
+ const aid=await uqFresh(i);
864
+ const scan=(await uqPost(`${UQ_BASE}/${aid}/scans`,{renew:true})).scan;
865
+ await uqPost(`/api/math/answer-scans/${scan.id}/confirm`);
866
+ const lease=await uqPost(`/api/math/answer-scans/${scan.id}/uploads`,{key:crypto.randomUUID()});
867
+ if(!lease.attachment){ const form=new FormData(); form.append("photo",photo,"answer.jpg");
868
+ await uqJson(`/api/math/answer-scans/${scan.id}/uploads/${lease.lease_id}`,{method:"POST",body:form}); }
869
+ }catch(e){ if(UQ?.uid===uid) UQ.msg[i]=e.message; }
870
+ if(UQ?.uid!==uid) return;
871
+ UQ.busy[i]=""; uqRefresh(i);
872
+ }
873
+ // the phone: a request with a 4-digit code, and a window here until the phone sends the photo or declines
874
+ async function uqPhone(i){
875
+ const uid=UQ.uid, r0=UQ.res[i];
876
+ let r=r0&&!r0.attachment&&["waiting","confirmed"].includes(r0.scan?.state)?r0:null; // a request still open: show its code again
877
+ if(!r){
878
+ UQ.busy[i]="ื™ื•ืฆืจื™ื ื‘ืงืฉืช ืฆื™ืœื•ืโ€ฆ"; UQ.msg[i]=""; uqDrawAnswer(i);
879
+ try{ const aid=await uqFresh(i); r=await uqPost(`${UQ_BASE}/${aid}/scans`,{renew:true}); }
880
+ catch(e){ if(UQ?.uid===uid) UQ.msg[i]=e.message; }
881
+ if(UQ?.uid!==uid) return;
882
+ UQ.busy[i]=""; uqDrawAnswer(i);
883
+ }
884
+ if(r) await window.MathlyAnswers.waitForPhone(r);
885
+ if(UQ?.uid===uid) uqRefresh(i);
886
+ }
887
+ function uqSubmit(timeUp=false){
888
+ const uid=UQ.uid, sit=uqState(uid).sitting; if(!sit||sit.submitted) return;
889
+ const missing=sit.attempts.filter((_,i)=>!UQ.res[i]?.attachment).length;
890
+ if(!timeUp&&missing&&!UQ.sure){
891
+ UQ.sure=true;
892
+ sheet.querySelector(".mx-quiz-nav").insertAdjacentHTML("beforebegin",`<p class="mx-small mx-quiz-warn">${missing===1?"ืœืฉืืœื” ืื—ืช":`ืœึพ${missing} ืฉืืœื•ืช`} ืื™ืŸ ืชืฉื•ื‘ื” ืžืฆื•ืœืžืช, ื•ื”ืŸ ื™ืงื‘ืœื• 0 ื ืงื•ื“ื•ืช. ืœื—ืฆื• ืฉื•ื‘ ืขืœ ื”ื’ืฉื” ื›ื“ื™ ืœื”ื’ื™ืฉ ื‘ื›ืœ ื–ืืช.</p>`);
893
+ return;
894
+ }
895
+ sit.submitted=Date.now();
896
+ const st=uqState(uid); st.history.push({at:new Date().toISOString(),attempts:sit.attempts.slice(),score:null,timeUp}); st.history=st.history.slice(-10);
897
+ save(); draw();
898
+ uqResult(uid,st.history.length-1,true);
899
+ }
900
+ // the report: each answer is checked now (once), then points per question, feedback and the solution
901
+ async function uqResult(uid,h,check=false){
902
+ const u=G.units.find(x=>x.id===uid), st=uqState(uid), entry=st.history[h];
903
+ if(!cur) back=document.activeElement;
904
+ stopUnitQuiz(); cur="uquiz"; UQ={uid,res:{},images:{}};
905
+ const n=entry.attempts.length;
906
+ sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
907
+ <div class="mx-sheet mx-uq-sheet u-${uid}" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
908
+ <div class="mx-shtop"><div><span class="mx-uchip">${esc(u.name)}</span><h3 id="mxTitle">ื‘ื•ื—ืŸ ืžืกื›ื: ื‘ื•ื“ืงื™ืโ€ฆ</h3>
909
+ <small class="mx-src" aria-live="polite">ื‘ื•ื“ืงื™ื ืืช ื”ืชืฉื•ื‘ื•ืช, ื–ื” ืœื•ืงื— ื›ื—ืฆื™ ื“ืงื” ืœืฉืืœื”</small></div>
910
+ <button class="mx-x" data-act="close" aria-label="ืกื’ื™ืจื”">โœ•</button></div><div class="mx-uq-report"></div></div>`;
911
+ sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
912
+ const results=[];
913
+ for(let i=0;i<n;i++){
914
+ let r=null;
915
+ try{
916
+ r=await uqJson(`${UQ_BASE}/${entry.attempts[i]}`);
917
+ if(r.attachment&&r.grading_available&&(!r.grading||(check&&r.grading.state==="failed"))){
918
+ if(UQ?.uid!==uid) return;
919
+ sheet.querySelector(".mx-src").textContent=`ื‘ื•ื“ืงื™ื ืืช ืฉืืœื” ${i+1} ืžืชื•ืš ${n}โ€ฆ`;
920
+ try{ r=await uqPost(`${UQ_BASE}/${entry.attempts[i]}/grade`,{automatic:!check}); }catch(e){ r.error=e.message; }
921
+ }
922
+ if(r.attachment){ const b=await api(`${UQ_BASE}/${entry.attempts[i]}/image`,{cache:"no-store"}); if(b.ok&&UQ?.uid===uid) UQ.images[i]=URL.createObjectURL(await b.blob()); }
923
+ }catch(e){ r={error:e.message}; }
924
+ if(UQ?.uid!==uid) return;
925
+ results.push(r);
926
+ }
927
+ // points: a checked answer earns its share; a missing one 0; one not checked yet leaves the total open
928
+ let earned=0, open=0;
929
+ const rows=u.quiz.questions.map((q,i)=>{
930
+ const r=results[i], g=r?.grading, a=g?.state==="completed"?g.result:null;
931
+ let pts=null, say="";
932
+ if(!r?.attachment) { pts=0; say="ืœื ืฆื•ืœืžื” ืชืฉื•ื‘ื”"; }
933
+ else if(a&&a.score!=null){ pts=Math.round(q.points*a.score/100); say={correct:"ื ื›ื•ืŸ ื•ืžืœื",partial:"ื ื›ื•ืŸ ื‘ื—ืœืงื•",incorrect:"ืœื ื ื›ื•ืŸ"}[a.verdict]||""; }
934
+ else { open++; say=a?.verdict==="unreadable"?"ืœื ื”ืฆืœื—ื ื• ืœืงืจื•ื ืืช ื”ืชืžื•ื ื”":a?.verdict==="needs_review"?"ื ื“ืจืฉืช ื‘ื“ื™ืงื” ืฉืœ ืžื•ืจื”":r.error||(!r.grading_available?"ื”ื‘ื“ื™ืงื” ื”ืื•ื˜ื•ืžื˜ื™ืช ืื™ื ื” ื–ืžื™ื ื” ื›ืจื’ืข":"ืขื•ื“ ืœื ื ื‘ื“ืงื”"); }
935
+ if(pts!=null) earned+=pts;
936
+ return `<li class="mx-uq-q"><div class="mx-uq-qhead"><b>ืฉืืœื” ${i+1}</b><span>${pts==null?"โ€“":pts}/${q.points} ื ืงืณ</span></div>
937
+ <p class="mx-uq-text">${fmt(q.question).replace(/\n/g,"<br>")}</p>
938
+ ${UQ.images[i]?`<img class="mx-uq-img" alt="ื”ืชืฉื•ื‘ื” ืฉืœื›ื ืœืฉืืœื” ${i+1}" src="${UQ.images[i]}">`:""}
939
+ <p class="mx-uq-verdict ${a?.verdict||"none"}">${esc(say)}</p>
940
+ ${a?.feedback?`<p>${fmtLoose(a.feedback)}</p>`:""}${a?.next_step?`<p><b>ื”ืฆืขื“ ื”ื‘ื: </b>${fmtLoose(a.next_step)}</p>`:""}
941
+ ${a?.solution?`<details><summary>ื”ืคืชืจื•ืŸ ื”ืžืœื</summary><p class="mx-grade-solution">${fmtLoose(a.solution)}</p></details>`:""}
942
+ ${r?.attachment&&!a&&r.grading_available?`<button class="mx-btn" data-uq-result="${uid}:${h}:check">ื‘ื“ื™ืงื” ื ื•ืกืคืช</button>`:""}</li>`;
943
+ });
944
+ entry.score=open?null:earned; save(); draw();
945
+ const total=open?`${earned} ื ืงื•ื“ื•ืช ืขื“ ืขื›ืฉื™ื• ยท ${open===1?"ืฉืืœื” ืื—ืช ืœื ื ื‘ื“ืงื”":`${open} ืฉืืœื•ืช ืœื ื ื‘ื“ืงื•`}`:`${earned}/100`;
946
+ sheet.querySelector("#mxTitle").textContent=`ื‘ื•ื—ืŸ ืžืกื›ื: ${total}`;
947
+ sheet.querySelector(".mx-src").textContent=`ื”ื•ื’ืฉ ${new Date(entry.at).toLocaleString("he-IL",{dateStyle:"short",timeStyle:"short"})}${entry.timeUp?" ยท ื”ื•ื’ืฉ ื›ืฉื”ื–ืžืŸ ื ื’ืžืจ":""}`;
948
+ const weak=u.quiz.questions.map((q,i)=>i).filter(i=>{ const a=results[i]?.grading?.result; return !results[i]?.attachment||(a&&a.score!=null&&a.score<70); });
949
+ sheet.querySelector(".mx-uq-report").innerHTML=`<ol class="mx-uq-qs">${rows.join("")}</ol>
950
+ ${weak.length&&!open?`<p class="mx-note">ื›ื“ืื™ ืœื—ื–ื•ืจ ืขืœ ${weak.map(i=>`ืฉืืœื” ${i+1}`).join(", ")} ื•ืœืชืจื’ืœ ืืช ื”ืžื™ื•ืžื ื•ื™ื•ืช ืฉืœ ื”ื™ื—ื™ื“ื” ื‘ืžืกืœื•ืœ ืœืคื ื™ ื”ื‘ื•ื—ืŸ ื”ื‘ื.</p>`:""}
951
+ <p class="mx-small">ื”ื‘ื“ื™ืงื” ืื•ื˜ื•ืžื˜ื™ืช ื‘ืืžืฆืขื•ืช AI: ื‘ื“ืงื• ืฉื”ื™ื ืชื•ืืžืช ืœืžื” ืฉื›ืชื‘ืชื, ื•ืื ืžืฉื”ื• ื ืจืื” ืœื ื ื›ื•ืŸ, ื”ืจืื• ืœืžื•ืจื”.</p>
952
+ <div class="mx-quiz-nav"><button class="mx-btn" data-uquiz="${uid}">ืœื‘ื•ื—ืŸ</button><button class="mx-btn solid" data-act="close">ื—ื–ืจื” ืœืžืกืœื•ืœ</button></div>`;
953
+ }
954
+
955
  function onClick(e){
956
  const t=e.target.closest("button,[data-act]"); if(!t||!root.contains(t)) return;
957
  if(t.dataset.choice!=null) return answer(t);
958
+ if(t.dataset.quizUnit) return quizToggle(t.dataset.quizUnit);
959
+ if(t.dataset.quizGo!=null) return quizGo(+t.dataset.quizGo);
960
+ if(t.dataset.uquiz) return uqIntro(t.dataset.uquiz); // a sitting in progress opens straight on its paper
961
+ if(t.dataset.uqResult){ const [uid,h,check]=t.dataset.uqResult.split(":"); return uqResult(uid,+h,check==="check"); }
962
+ if(t.dataset.uqPhoto!=null){ UQ.pick=+t.dataset.uqPhoto; return sheet.querySelector("[data-uq-file]").click(); }
963
+ if(t.dataset.uqPhone!=null) return uqPhone(+t.dataset.uqPhone);
964
  if(t.dataset.subject) return selectSubject(t.dataset.subject);
965
  if(t.dataset.view){ closeSheet(); return setView(t.dataset.view,t.dataset.unit); }
966
  if(t.dataset.comet) return showComet(t.dataset.comet);
 
975
  case "subjects": return drawSubjectPicker();
976
  case "streak": return showStreak();
977
  case "review": return startReview();
978
+ case "patterns": return showPatterns();
979
+ case "quiz": stopQuizClock(); QZ=null; return quizSetup();
980
+ case "quiz-start": return quizStart();
981
+ case "quiz-next": return quizGo(QZ.i+1);
982
+ case "quiz-prev": return quizGo(QZ.i-1);
983
+ case "quiz-submit": return quizSubmit();
984
+ case "quiz-quit": return quizQuit();
985
+ case "uq-start": return uqStart();
986
+ case "uq-submit": return uqSubmit();
987
  case "review-next": return REV&&reviewNext();
988
  case "cal-prev": return showStreak(-1);
989
  case "cal-next": return showStreak(1);
supabase/migrations/20261103120000_math_answer_scan_decline.sql ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- A photo request can be declined: the phone refuses it, or the computer withdraws it while waiting.
2
+ -- Additive: one more scan state and one more action; existing rows and actions are unchanged.
3
+ begin;
4
+ alter table public.math_answer_scans drop constraint math_answer_scans_state_check;
5
+ alter table public.math_answer_scans add constraint math_answer_scans_state_check
6
+ check (state in ('waiting','confirmed','uploading','received','failed','expired','superseded','declined'));
7
+ create or replace function public.math_answer_action(p_user uuid, p_action text, p_data jsonb default '{}')
8
+ returns jsonb language plpgsql security invoker set search_path = '' as $$
9
+ declare
10
+ a public.math_question_attempts;
11
+ s public.math_answer_scans;
12
+ att public.math_answer_attachments;
13
+ t timestamptz := clock_timestamp();
14
+ sid uuid;
15
+ aid uuid;
16
+ listing jsonb;
17
+ begin
18
+ if p_action='cleanup_candidates' then
19
+ return coalesce((select jsonb_agg(o.name) from storage.objects o
20
+ where o.bucket_id='mathly-answers' and o.created_at < t-interval '24 hours'
21
+ and not exists(select 1 from public.math_answer_attachments x where x.object_key=o.name)), '[]'::jsonb);
22
+ end if;
23
+ if p_user is null then raise exception 'not_found' using errcode='P0002'; end if;
24
+ if p_action='pending' then
25
+ -- Expiry and stale lease recovery are also persisted on discovery.
26
+ update public.math_answer_scans set state='expired',error='expired'
27
+ where user_id=p_user and expires_at<=t and state in ('waiting','confirmed','uploading','failed');
28
+ update public.math_answer_scans set state='failed',error='upload_interrupted'
29
+ where user_id=p_user and state='uploading' and lease_until<=t;
30
+ select jsonb_agg(jsonb_build_object('scan',to_jsonb(x),'attempt',to_jsonb(q)) order by x.created_at desc)
31
+ into listing from public.math_answer_scans x join public.math_question_attempts q on q.id=x.attempt_id
32
+ where x.user_id=p_user and x.state in ('waiting','confirmed','uploading','failed','expired')
33
+ and not exists(select 1 from public.math_answer_scans newer where newer.attempt_id=x.attempt_id and newer.generation>x.generation);
34
+ return coalesce(listing,'[]'::jsonb);
35
+ end if;
36
+ if p_action='resume' then
37
+ perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(p_user::text||(p_data->>'question_id')||(p_data->>'question_version'),0));
38
+ if not coalesce((p_data->>'new')::boolean,false) then
39
+ select * into a from public.math_question_attempts where user_id=p_user
40
+ and question_id=p_data->>'question_id' and question_version=p_data->>'question_version'
41
+ order by created_at desc,id desc limit 1 for update;
42
+ end if;
43
+ if a.id is null then
44
+ insert into public.math_question_attempts(user_id,question_id,question_version,snapshot)
45
+ values(p_user,p_data->>'question_id',p_data->>'question_version',p_data->'snapshot') returning * into a;
46
+ end if;
47
+ else
48
+ if p_data ? 'scan_id' then
49
+ select attempt_id into aid from public.math_answer_scans where id=(p_data->>'scan_id')::uuid and user_id=p_user;
50
+ else aid := (p_data->>'attempt_id')::uuid;
51
+ end if;
52
+ select * into a from public.math_question_attempts where id=aid and user_id=p_user for update;
53
+ if a.id is null then raise exception 'not_found' using errcode='P0002'; end if;
54
+ if p_data ? 'question_id' and a.question_id<>p_data->>'question_id' then
55
+ raise exception 'not_found' using errcode='P0002';
56
+ end if;
57
+ end if;
58
+ select * into s from public.math_answer_scans where attempt_id=a.id order by generation desc limit 1 for update;
59
+ select * into att from public.math_answer_attachments where attempt_id=a.id;
60
+ t := clock_timestamp();
61
+ if p_data ? 'scan_id' and s.id is distinct from (p_data->>'scan_id')::uuid then
62
+ raise exception 'superseded' using errcode='P0001';
63
+ end if;
64
+ if s.id is not null then
65
+ if s.state in ('waiting','confirmed','uploading','failed') and s.expires_at<=t then
66
+ update public.math_answer_scans set state='expired',error='expired' where id=s.id returning * into s;
67
+ elsif s.state='uploading' and s.lease_until<=t then
68
+ update public.math_answer_scans set state='failed',error='upload_interrupted' where id=s.id returning * into s;
69
+ end if;
70
+ end if;
71
+ if p_action='create_scan' then
72
+ if att.id is not null then null;
73
+ elsif s.id is null or coalesce((p_data->>'renew')::boolean,false) then
74
+ if s.state='uploading' then raise exception 'upload_in_progress' using errcode='P0001'; end if;
75
+ if s.id is not null then update public.math_answer_scans set state='superseded' where id=s.id; end if;
76
+ insert into public.math_answer_scans(user_id,attempt_id,generation,expires_at,created_at)
77
+ values(p_user,a.id,coalesce(s.generation,0)+1,t+interval '15 minutes',t) returning * into s;
78
+ end if;
79
+ elsif p_action='confirm' then
80
+ if s.state='waiting' then
81
+ update public.math_answer_scans set state='confirmed' where id=s.id returning * into s;
82
+ end if;
83
+ elsif p_action='begin' then
84
+ if s.state in ('confirmed','failed') then
85
+ update public.math_answer_scans set state='uploading',lease_id=gen_random_uuid(),
86
+ lease_until=least(t+interval '3 minutes',expires_at),idempotency_key=(p_data->>'key')::uuid,error=''
87
+ where id=s.id returning * into s;
88
+ elsif s.state='uploading' and s.idempotency_key is distinct from (p_data->>'key')::uuid then
89
+ raise exception 'upload_in_progress' using errcode='P0001';
90
+ elsif s.state='waiting' then raise exception 'confirmation_required' using errcode='P0001';
91
+ end if;
92
+ elsif p_action='finalize' then
93
+ if att.id is null then
94
+ if s.state<>'uploading' or s.lease_id is distinct from (p_data->>'lease_id')::uuid then
95
+ raise exception 'invalid_upload' using errcode='P0001';
96
+ end if;
97
+ if (p_data->>'object_key') not like p_user::text||'/'||s.id::text||'/'||s.lease_id::text||'/%' then
98
+ raise exception 'invalid_upload' using errcode='P0001';
99
+ end if;
100
+ insert into public.math_answer_attachments(user_id,attempt_id,scan_id,object_key,media_type,byte_size)
101
+ values(p_user,a.id,s.id,p_data->>'object_key','image/jpeg',(p_data->>'byte_size')::integer) returning * into att;
102
+ update public.math_answer_scans set state='received',error='' where id=s.id returning * into s;
103
+ update public.math_question_attempts set status='received' where id=a.id returning * into a;
104
+ end if;
105
+ elsif p_action='decline' then
106
+ -- the phone refused the request, or the computer withdrew it; a received answer stays received
107
+ if att.id is null and s.state in ('waiting','confirmed','failed') then
108
+ update public.math_answer_scans set state='declined',error='declined' where id=s.id returning * into s;
109
+ end if;
110
+ elsif p_action='fail' then
111
+ if s.state='uploading' and s.lease_id=(p_data->>'lease_id')::uuid then
112
+ update public.math_answer_scans set state='failed',error='upload_failed' where id=s.id returning * into s;
113
+ end if;
114
+ elsif p_action not in ('resume','get') then
115
+ raise exception 'invalid_action';
116
+ end if;
117
+ return jsonb_build_object('attempt',to_jsonb(a),'scan',case when s.id is null then null else to_jsonb(s) end,
118
+ 'attachment',case when att.id is null then null else to_jsonb(att) end);
119
+ end $$;
120
+ revoke all on function public.math_answer_action(uuid,text,jsonb) from public,anon,authenticated;
121
+ grant execute on function public.math_answer_action(uuid,text,jsonb) to service_role;
122
+ commit;
tests/scan_support.py CHANGED
@@ -117,7 +117,9 @@ def answer_store(postgres_server, tmp_path):
117
  with store.connect() as c:
118
  c.execute(BOOTSTRAP)
119
  c.execute(Path('supabase/migrations/20261031133254_math_answer_scans.sql').read_text())
120
- for migration in sorted(Path('supabase/migrations').glob('*_math_answer_grading.sql')):
 
 
121
  c.execute(migration.read_text())
122
  c.execute('insert into auth.users values(%s),(%s)', (OWNER, OTHER))
123
  yield store
 
117
  with store.connect() as c:
118
  c.execute(BOOTSTRAP)
119
  c.execute(Path('supabase/migrations/20261031133254_math_answer_scans.sql').read_text())
120
+ for migration in sorted(Path('supabase/migrations').glob('*_math_answer_*.sql')):
121
+ if migration.name.endswith('_math_answer_scans.sql'):
122
+ continue
123
  c.execute(migration.read_text())
124
  c.execute('insert into auth.users values(%s),(%s)', (OWNER, OTHER))
125
  yield store
tests/test_answer_browser.py CHANGED
@@ -16,6 +16,12 @@ from app.math_photo import PhotoUnavailable
16
  playwright = pytest.importorskip('playwright.sync_api')
17
 
18
 
 
 
 
 
 
 
19
  @pytest.fixture
20
  def scan_server(answer_store):
21
  app = create_app(auth=ScanAuth()); app.state.answers = answer_store
@@ -67,26 +73,29 @@ def test_two_device_capture_retake_failure_retry_and_original_attempt(scan_serve
67
  assert len(resume.value.post_data_json['question_version'])==64
68
  computer.locator('[data-request]').click()
69
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ')
 
 
 
 
70
  with store.connect() as c:
71
  attempt_id = str(c.execute('select id from math_question_attempts').fetchone()[0])
72
  phone = session(browser, mobile=True).new_page()
73
  phone.on('pageerror', lambda error: errors.append(str(error)))
74
- request(store, question='another-question')
75
  phone.goto(url)
76
- playwright.expect(phone.locator('[data-scan-index]')).to_have_count(2)
77
- phone.locator('[data-scan-index]').filter(has_text='ืžืฉื•ืœืฉ ื™ืฉืจ').click()
78
- # Confirmation and cancellation do not invoke capture or upload.
79
- assert phone.locator('[data-camera]').input_value() == ''
80
- assert phone.locator('[data-take]').is_hidden()
 
81
  phone.locator('[data-close]').click()
82
- phone.locator('[data-scan-index]').filter(has_text='ืžืฉื•ืœืฉ ื™ืฉืจ').click()
83
- phone.evaluate("window.cameraClicks=0;document.addEventListener('click',e=>{if(e.target.matches('[data-camera]'))window.cameraClicks++},true)")
84
- phone.locator('[data-confirm]').click()
85
- playwright.expect(phone.locator('[data-take]')).to_be_visible()
86
  phone.locator('[data-camera]').dispatch_event('cancel')
87
  playwright.expect(phone.locator('[data-status]')).to_contain_text('ืœื ื ื™ืชื ื” ื”ืจืฉืื”')
88
  image = {'name': 'answer.png', 'mimeType': 'image/png', 'buffer': png()}
89
- assert phone.evaluate('window.cameraClicks')==0
90
  with phone.expect_file_chooser() as chooser:
91
  phone.locator('[data-take]').click()
92
  chooser.value.set_files(image)
@@ -108,14 +117,11 @@ def test_two_device_capture_retake_failure_retry_and_original_attempt(scan_serve
108
  started.set(); assert release.wait(timeout=15); original_put(key, data)
109
  store.put = slow_put
110
  phone.locator('[data-submit]').click()
111
- playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืขืœื™ื ืืช ื”ืชืฉื•ื‘ื”โ€ฆ', timeout=10000)
112
  assert started.is_set()
113
- computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="m_def"]').click()
114
  release.set()
115
- playwright.expect(phone.locator('[data-status]')).to_contain_text('ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื”', timeout=10000)
116
- assert computer.locator('.mx-answer-image').count() == 0
117
- assert computer.locator('.mx-choices button').count() > 0
118
- computer.locator('.mx-x').click();open_question(computer)
119
  playwright.expect(computer.locator('.mx-answer-image')).to_be_visible()
120
  assert computer.locator('.mx-answer-image').evaluate('img=>img.complete&&img.naturalWidth>0')
121
  computer.screenshot(path='/tmp/mathly-answer-computer.png', full_page=True, animations='disabled')
@@ -123,7 +129,8 @@ def test_two_device_capture_retake_failure_retry_and_original_attempt(scan_serve
123
  computer.reload();open_question(computer)
124
  playwright.expect(computer.locator('.mx-answer-image')).to_be_visible()
125
  foreign = session(browser, OTHER, mobile=True).new_page();foreign.goto(url)
126
- playwright.expect(foreign.locator('.mx-scan-inbox')).to_be_empty()
 
127
  assert store.action(OWNER, 'get', attempt_id=attempt_id)['attachment']
128
  assert not errors
129
  finally:
@@ -143,16 +150,16 @@ def test_expiry_renewal_reconnect_and_account_change(scan_server):
143
  c.execute("update math_answer_scans set expires_at=now()-interval '1 second'")
144
  computer.route('**/api/math/answer-attempts/*', lambda r:r.abort(), times=1)
145
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืคื’ื”', timeout=15000)
 
146
  phone = session(browser, mobile=True).new_page();phone.goto(url)
147
- phone.locator('[data-scan-index]').click()
148
- playwright.expect(phone.locator('[data-renew]')).to_be_visible()
149
- phone.locator('[data-renew]').click()
150
- playwright.expect(phone.locator('[data-confirm]')).to_be_visible()
151
  assert store.action(OWNER, 'pending')[0]['scan']['generation'] == 2
152
- phone.locator('[data-confirm]').click()
153
  phone.locator('[data-camera]').set_input_files({'name':'answer.png','mimeType':'image/png','buffer':png()})
154
  phone.locator('[data-submit]').click()
155
- playwright.expect(phone.locator('[data-status]')).to_contain_text('ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื”')
156
  playwright.expect(computer.locator('.mx-answer-image')).to_be_visible(timeout=10000)
157
  # Account change removes the photograph and all old-account continuations.
158
  computer.evaluate("localStorage.setItem('test.account','%s');window.testAuthEvent('SIGNED_IN',{access_token:'%s',user:{id:'%s'}})" % (OTHER, OTHER, OTHER))
@@ -174,7 +181,7 @@ def test_success_response_loss_is_safe_to_retry(scan_server):
174
  computer.locator('[data-request]').click()
175
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ')
176
  phone=session(browser,mobile=True).new_page();phone.goto(url)
177
- phone.locator('[data-scan-index]').click();phone.locator('[data-confirm]').click()
178
  phone.locator('[data-camera]').set_input_files({'name':'answer.png','mimeType':'image/png','buffer':png()})
179
  def lost_response(route):
180
  response=route.fetch()
@@ -185,7 +192,7 @@ def test_success_response_loss_is_safe_to_retry(scan_server):
185
  playwright.expect(phone.locator('[data-status]')).to_contain_text('ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘')
186
  playwright.expect(computer.locator('.mx-answer-image')).to_be_visible(timeout=10000)
187
  phone.locator('[data-submit]').click()
188
- playwright.expect(phone.locator('[data-status]')).to_contain_text('ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื”')
189
  with store.connect() as c:
190
  assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1
191
  computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="m_def"]').click()
@@ -279,11 +286,11 @@ def test_upload_is_acknowledged_before_automatic_grading_finishes(scan_server, m
279
  computer.locator('[data-request]').click()
280
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ')
281
  phone=session(browser,mobile=True).new_page();phone.goto(url)
282
- phone.locator('[data-scan-index]').click();phone.locator('[data-confirm]').click()
283
  phone.locator('[data-camera]').set_input_files({'name':'answer.png','mimeType':'image/png','buffer':png()})
284
  playwright.expect(phone.locator('.mx-scan-card')).to_contain_text('ื™ื™ืฉืœื— ืœื‘ื“ื™ืงื” ื‘ืืžืฆืขื•ืช AI')
285
  phone.locator('[data-submit]').click()
286
- playwright.expect(phone.locator('[data-status]')).to_contain_text('ื”ื•ืขืœืชื” ื‘ื”ืฆืœื—ื”')
287
  assert started.wait(timeout=5)
288
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ื‘ื•ื“ืงื™ื ืืช ื”ืชืฉื•ื‘ื”โ€ฆ')
289
  assert len(calls)==1
@@ -292,3 +299,36 @@ def test_upload_is_acknowledged_before_automatic_grading_finishes(scan_server, m
292
  assert len(calls)==1
293
  finally:
294
  release.set();browser.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  playwright = pytest.importorskip('playwright.sync_api')
17
 
18
 
19
+ @pytest.fixture(autouse=True)
20
+ def unlocked_open_ended(monkeypatch):
21
+ """These tests answer the open-ended question, which is locked by default."""
22
+ monkeypatch.setenv('MATHLY_OPEN_ENDED_LOCKED', '0')
23
+
24
+
25
  @pytest.fixture
26
  def scan_server(answer_store):
27
  app = create_app(auth=ScanAuth()); app.state.answers = answer_store
 
73
  assert len(resume.value.post_data_json['question_version'])==64
74
  computer.locator('[data-request]').click()
75
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ')
76
+ wait = computer.locator('.mx-scan-wait')
77
+ playwright.expect(wait.locator('.mx-scan-code span')).to_have_count(4) # the computer waits with a 4-digit code
78
+ code = wait.locator('.mx-scan-code').inner_text().replace('\n', '')
79
+ assert len(code) == 4 and code.isdigit()
80
  with store.connect() as c:
81
  attempt_id = str(c.execute('select id from math_question_attempts').fetchone()[0])
82
  phone = session(browser, mobile=True).new_page()
83
  phone.on('pageerror', lambda error: errors.append(str(error)))
84
+ other = request(store, question='another-question')
85
  phone.goto(url)
86
+ # the newest request pops up first, as a window; declining it leaves the other one
87
+ ask = phone.locator('.mx-scan-overlay')
88
+ playwright.expect(ask).to_contain_text('Draw your proof')
89
+ phone.locator('[data-decline]').click()
90
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ืกื™ืจื‘ืชื')
91
+ assert store.action(OWNER, 'get', scan_id=other['scan']['id'])['scan']['state'] == 'declined'
92
  phone.locator('[data-close]').click()
93
+ playwright.expect(ask).to_contain_text('ืžืฉื•ืœืฉ ื™ืฉืจ', timeout=10000)
94
+ # the phone shows the same code as the computer
95
+ assert ask.locator('.mx-scan-code').inner_text().replace('\n', '') == code
 
96
  phone.locator('[data-camera]').dispatch_event('cancel')
97
  playwright.expect(phone.locator('[data-status]')).to_contain_text('ืœื ื ื™ืชื ื” ื”ืจืฉืื”')
98
  image = {'name': 'answer.png', 'mimeType': 'image/png', 'buffer': png()}
 
99
  with phone.expect_file_chooser() as chooser:
100
  phone.locator('[data-take]').click()
101
  chooser.value.set_files(image)
 
117
  started.set(); assert release.wait(timeout=15); original_put(key, data)
118
  store.put = slow_put
119
  phone.locator('[data-submit]').click()
120
+ playwright.expect(wait).to_contain_text('ืžืงื‘ืœื™ื ืืช ื”ืชืžื•ื ื” ืžื”ื˜ืœืคื•ืŸ', timeout=10000)
121
  assert started.is_set()
 
122
  release.set()
123
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ื ืฉืœื—ื” ืœืžื—ืฉื‘', timeout=10000)
124
+ playwright.expect(wait).to_have_count(0, timeout=10000) # the computer's window closes with the photo
 
 
125
  playwright.expect(computer.locator('.mx-answer-image')).to_be_visible()
126
  assert computer.locator('.mx-answer-image').evaluate('img=>img.complete&&img.naturalWidth>0')
127
  computer.screenshot(path='/tmp/mathly-answer-computer.png', full_page=True, animations='disabled')
 
129
  computer.reload();open_question(computer)
130
  playwright.expect(computer.locator('.mx-answer-image')).to_be_visible()
131
  foreign = session(browser, OTHER, mobile=True).new_page();foreign.goto(url)
132
+ foreign.wait_for_timeout(1500)
133
+ playwright.expect(foreign.locator('.mx-scan-overlay')).to_have_count(0)
134
  assert store.action(OWNER, 'get', attempt_id=attempt_id)['attachment']
135
  assert not errors
136
  finally:
 
150
  c.execute("update math_answer_scans set expires_at=now()-interval '1 second'")
151
  computer.route('**/api/math/answer-attempts/*', lambda r:r.abort(), times=1)
152
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ื‘ืงืฉืช ื”ืฆื™ืœื•ื ืคื’ื”', timeout=15000)
153
+ playwright.expect(computer.locator('.mx-scan-wait')).to_contain_text('ื”ื‘ืงืฉื” ืคื’ื”')
154
  phone = session(browser, mobile=True).new_page();phone.goto(url)
155
+ phone.wait_for_timeout(1500)
156
+ playwright.expect(phone.locator('.mx-scan-overlay')).to_have_count(0) # an expired request does not pop up
157
+ computer.locator('[data-again]').click()
158
+ playwright.expect(phone.locator('.mx-scan-overlay')).to_be_visible(timeout=10000)
159
  assert store.action(OWNER, 'pending')[0]['scan']['generation'] == 2
 
160
  phone.locator('[data-camera]').set_input_files({'name':'answer.png','mimeType':'image/png','buffer':png()})
161
  phone.locator('[data-submit]').click()
162
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ื ืฉืœื—ื” ืœืžื—ืฉื‘')
163
  playwright.expect(computer.locator('.mx-answer-image')).to_be_visible(timeout=10000)
164
  # Account change removes the photograph and all old-account continuations.
165
  computer.evaluate("localStorage.setItem('test.account','%s');window.testAuthEvent('SIGNED_IN',{access_token:'%s',user:{id:'%s'}})" % (OTHER, OTHER, OTHER))
 
181
  computer.locator('[data-request]').click()
182
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ')
183
  phone=session(browser,mobile=True).new_page();phone.goto(url)
184
+ playwright.expect(phone.locator('.mx-scan-overlay')).to_be_visible()
185
  phone.locator('[data-camera]').set_input_files({'name':'answer.png','mimeType':'image/png','buffer':png()})
186
  def lost_response(route):
187
  response=route.fetch()
 
192
  playwright.expect(phone.locator('[data-status]')).to_contain_text('ืืคืฉืจ ืœื ืกื•ืช ืฉื•ื‘')
193
  playwright.expect(computer.locator('.mx-answer-image')).to_be_visible(timeout=10000)
194
  phone.locator('[data-submit]').click()
195
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ื ืฉืœื—ื” ืœืžื—ืฉื‘')
196
  with store.connect() as c:
197
  assert c.execute('select count(*) from math_answer_attachments').fetchone()[0]==1
198
  computer.locator('.mx-x').click();computer.locator('.mx-node[data-id="m_def"]').click()
 
286
  computer.locator('[data-request]').click()
287
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ืžืžืชื™ื ื™ื ืœื˜ืœืคื•ืŸ')
288
  phone=session(browser,mobile=True).new_page();phone.goto(url)
289
+ playwright.expect(phone.locator('.mx-scan-overlay')).to_be_visible()
290
  phone.locator('[data-camera]').set_input_files({'name':'answer.png','mimeType':'image/png','buffer':png()})
291
  playwright.expect(phone.locator('.mx-scan-card')).to_contain_text('ื™ื™ืฉืœื— ืœื‘ื“ื™ืงื” ื‘ืืžืฆืขื•ืช AI')
292
  phone.locator('[data-submit]').click()
293
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ื ืฉืœื—ื” ืœืžื—ืฉื‘')
294
  assert started.wait(timeout=5)
295
  playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ื‘ื•ื“ืงื™ื ืืช ื”ืชืฉื•ื‘ื”โ€ฆ')
296
  assert len(calls)==1
 
299
  assert len(calls)==1
300
  finally:
301
  release.set();browser.close()
302
+
303
+
304
+ def test_phone_declines_and_computer_withdraws(scan_server):
305
+ url, store = scan_server
306
+ with playwright.sync_playwright() as p:
307
+ browser = p.chromium.launch()
308
+ try:
309
+ computer = session(browser).new_page(); errors = []
310
+ computer.on('pageerror', lambda error: errors.append(str(error)))
311
+ computer.goto(url); open_question(computer)
312
+ computer.locator('[data-request]').click()
313
+ wait = computer.locator('.mx-scan-wait')
314
+ playwright.expect(wait.locator('.mx-scan-code span')).to_have_count(4)
315
+ phone = session(browser, mobile=True).new_page()
316
+ phone.on('pageerror', lambda error: errors.append(str(error)))
317
+ phone.goto(url)
318
+ # the phone declines: the computer reports it
319
+ phone.locator('[data-decline]').click()
320
+ playwright.expect(wait).to_contain_text('ื”ื‘ืงืฉื” ื ื“ื—ืชื” ื‘ื˜ืœืคื•ืŸ', timeout=10000)
321
+ computer.locator('[data-done]').click()
322
+ playwright.expect(wait).to_have_count(0)
323
+ playwright.expect(computer.locator('.mx-answer [role=status]')).to_have_text('ื”ื‘ืงืฉื” ื ื“ื—ืชื” ื‘ื˜ืœืคื•ืŸ')
324
+ phone.locator('[data-close]').click()
325
+ # a new request pops up again; the computer withdraws it and the phone's window says so
326
+ computer.locator('[data-request]').click()
327
+ playwright.expect(phone.locator('.mx-scan-overlay [data-take]')).to_be_visible(timeout=10000)
328
+ computer.locator('[data-cancel]').click()
329
+ playwright.expect(wait).to_have_count(0)
330
+ playwright.expect(phone.locator('[data-status]')).to_contain_text('ื‘ื•ื˜ืœื” ื‘ืžื—ืฉื‘', timeout=10000)
331
+ assert store.action(OWNER, 'pending') == []
332
+ assert not errors
333
+ finally:
334
+ browser.close()
tests/test_sky_browser.py CHANGED
@@ -43,7 +43,7 @@ def browser():
43
  b.close()
44
 
45
 
46
- def page_with(browser, server, progress=None, no_chance=False):
47
  ctx = browser.new_context(viewport={"width": 390, "height": 844})
48
  if no_chance: # no comet by chance: only the sure ones come
49
  ctx.add_init_script("Math.random=()=>0.99")
@@ -53,6 +53,8 @@ def page_with(browser, server, progress=None, no_chance=False):
53
  if progress is not None:
54
  ctx.add_init_script(f"localStorage.setItem({json.dumps(KEY)},{json.dumps(json.dumps(progress))})")
55
  page = ctx.new_page()
 
 
56
  errors = []
57
  page.on("pageerror", lambda e: errors.append(str(e)))
58
  page.goto(server)
@@ -169,6 +171,8 @@ def test_a_mistake_is_quiet_hides_the_answer_and_needs_a_clean_run_to_finish(bro
169
  playwright.expect(page.locator(".mx-bad")).to_be_visible()
170
  assert page.locator(".mx-choices button.right").count() == 0 # the right answer is not revealed
171
  assert page.locator(".mx-choices button:not([disabled])").count() == 0
 
 
172
  page.locator(".mx-bad button[data-id=t_ext]").first.click()
173
  page.locator(".mx-choices button[data-right]").first.click() # it comes back and is solved
174
  playwright.expect(page.locator(".mx-ok b")).to_have_text(re.compile("ืชื™ืงื ืชื|ื”ื˜ืขื•ืช ืžืงื•ื“ื")) # fixing is praised
@@ -253,3 +257,242 @@ def test_answers_saved_by_a_page_from_before_rounds_are_kept(browser, server):
253
  playwright.expect(page.locator(".mx-qprog span").first).to_have_text("ืฉืืœื” 5 ืžืชื•ืš 10")
254
  playwright.expect(page.locator(".mx-qdots i.ok")).to_have_count(4)
255
  assert not errors
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  b.close()
44
 
45
 
46
+ def page_with(browser, server, progress=None, no_chance=False, now=None):
47
  ctx = browser.new_context(viewport={"width": 390, "height": 844})
48
  if no_chance: # no comet by chance: only the sure ones come
49
  ctx.add_init_script("Math.random=()=>0.99")
 
53
  if progress is not None:
54
  ctx.add_init_script(f"localStorage.setItem({json.dumps(KEY)},{json.dumps(json.dumps(progress))})")
55
  page = ctx.new_page()
56
+ if now: # the date the page sees; timers run as usual
57
+ page.clock.set_fixed_time(now)
58
  errors = []
59
  page.on("pageerror", lambda e: errors.append(str(e)))
60
  page.goto(server)
 
171
  playwright.expect(page.locator(".mx-bad")).to_be_visible()
172
  assert page.locator(".mx-choices button.right").count() == 0 # the right answer is not revealed
173
  assert page.locator(".mx-choices button:not([disabled])").count() == 0
174
+ slips = progress_of(page)["slips"]
175
+ assert [(s["id"], s["via"]) for s in slips] == [("t_ext", "practice")] and slips[0]["m"] # the mistake is remembered
176
  page.locator(".mx-bad button[data-id=t_ext]").first.click()
177
  page.locator(".mx-choices button[data-right]").first.click() # it comes back and is solved
178
  playwright.expect(page.locator(".mx-ok b")).to_have_text(re.compile("ืชื™ืงื ืชื|ื”ื˜ืขื•ืช ืžืงื•ื“ื")) # fixing is praised
 
257
  playwright.expect(page.locator(".mx-qprog span").first).to_have_text("ืฉืืœื” 5 ืžืชื•ืš 10")
258
  playwright.expect(page.locator(".mx-qdots i.ok")).to_have_count(4)
259
  assert not errors
260
+
261
+
262
+
263
+ def test_a_practice_exam_mixes_the_taught_units_and_reports_at_the_end(browser, server):
264
+ page, errors = page_with(browser, server, {"done": {"t_ext": "2026-10-01T00:00:00+00:00"}}, no_chance=True,
265
+ now=datetime(2026, 11, 3, 10))
266
+ page.locator(".mx-quiz-card").click()
267
+ sheet = page.locator(".mx-quiz-sheet")
268
+ on = sheet.locator('[data-quiz-unit][aria-pressed="true"]')
269
+ playwright.expect(on).to_have_count(4) # before November: algebra 1-2, the kite, the isosceles triangle
270
+ assert sorted(on.evaluate_all("els=>els.map(e=>e.dataset.quizUnit)")) == ["i", "k", "t", "t2"]
271
+ sheet.locator('[data-quiz-unit="k"]').click()
272
+ playwright.expect(on).to_have_count(3)
273
+ sheet.locator('[data-quiz-unit="k"]').click()
274
+ sheet.locator("[data-act=quiz-start]").click()
275
+ playwright.expect(sheet.locator(".mx-quiz-dots button")).to_have_count(12)
276
+ playwright.expect(sheet.locator("h3")).to_have_text("ืฉืืœื” 1 ืžืชื•ืš 12")
277
+ sheet.locator(".mx-choices button:not([data-right])").first.click() # the first one wrong
278
+ assert sheet.locator(".mx-ok, .mx-bad").count() == 0 # no feedback during the exam
279
+ for _ in range(10):
280
+ sheet.locator("[data-act=quiz-next]").click()
281
+ sheet.locator(".mx-choices button[data-right]").first.click()
282
+ sheet.locator("[data-act=quiz-next]").click() # the last one is left empty
283
+ sheet.locator("[data-act=quiz-submit]").click()
284
+ playwright.expect(sheet.locator(".mx-quiz-warn")).to_contain_text("ืฉืืœื” ืื—ืช ืœื ื ืขื ืชื”")
285
+ sheet.locator('[data-quiz-go="0"]').click() # back to the first: the choice is kept
286
+ playwright.expect(sheet.locator('.mx-choices button[aria-pressed="true"]')).to_have_count(1)
287
+ sheet.locator('[data-quiz-go="11"]').click()
288
+ sheet.locator("[data-act=quiz-submit]").click() # warned once: now it is handed in
289
+ playwright.expect(sheet.locator("h3")).to_have_text("10 ืžืชื•ืš 12 ื ื›ื•ื ื•ืช")
290
+ playwright.expect(sheet.locator(".mx-quiz-plan li")).to_have_count(1) # the one mistake, with its skill to practise
291
+ playwright.expect(sheet.locator(".mx-quiz-plan button[data-id]").first).to_be_visible()
292
+ playwright.expect(sheet.locator(".mx-quiz-units-score > div")).to_have_count(4)
293
+ p = progress_of(page)
294
+ q = p["quizzes"][-1]
295
+ assert (q["n"], q["right"], sorted(q["units"])) == (12, 10, ["i", "k", "t", "t2"]) and len(q["weak"]) == 2
296
+ assert p["days"]["2026-11-03"] == 10 # right answers count toward today's goal
297
+ page.locator(".mx-quiz-sheet .mx-x").click()
298
+ playwright.expect(page.locator(".mx-quiz-card")).to_contain_text("ื‘ืคืขื ื”ืงื•ื“ืžืช: 10 ืžืชื•ืš 12")
299
+ assert not errors
300
+
301
+
302
+ def test_leaving_a_practice_exam_asks_first(browser, server):
303
+ page, errors = page_with(browser, server, now=datetime(2026, 11, 3, 10))
304
+ page.locator(".mx-quiz-card").click()
305
+ page.locator("[data-act=quiz-start]").click()
306
+ page.locator(".mx-choices button").first.click()
307
+ page.keyboard.press("Escape")
308
+ playwright.expect(page.locator(".mx-quiz-warn")).to_contain_text("ืœืฆืืช?")
309
+ playwright.expect(page.locator(".mx-quiz-sheet")).to_be_visible()
310
+ page.locator("[data-act=quiz-quit]").click()
311
+ playwright.expect(page.locator(".mx-quiz-sheet")).to_be_hidden()
312
+ assert "quizzes" not in progress_of(page)
313
+ assert not errors
314
+
315
+
316
+ # ---------- misconception patterns ----------
317
+
318
+ def slips_at(now, *rows):
319
+ """Mistake-log entries: (days ago, skill, tag[, via])."""
320
+ return [{"at": (now - timedelta(days=d)).isoformat(), "id": sid, "q": 0, "m": m, "via": (via or ["practice"])[0]}
321
+ for d, sid, m, *via in rows]
322
+
323
+
324
+ def patterns_of(page, slips, mistakes=None):
325
+ return page.evaluate("([slips,mistakes])=>window.MathlySky.patterns({slips},mistakes||{})", [slips, mistakes])
326
+
327
+
328
+ def test_old_progress_without_a_mistake_log_loads(browser, server):
329
+ page, errors = page_with(browser, server, {"done": {}, "comets": []})
330
+ page.locator('.mx-star[data-id="t_ext"]').click()
331
+ page.locator(".mx-choices button:not([data-right])").first.click()
332
+ playwright.expect(page.locator(".mx-bad")).to_be_visible()
333
+ assert len(progress_of(page)["slips"]) == 1
334
+ assert not errors
335
+
336
+
337
+ def test_the_mistake_log_keeps_the_newest_500(browser, server):
338
+ page, errors = page_with(browser, server)
339
+ log = page.evaluate("""()=>{ const P={}; for(let i=0;i<501;i++) window.MathlySky.slip(P,"t_ext",i,"sign_mid","practice");
340
+ return P.slips.map(s=>s.q); }""")
341
+ assert len(log) == 500 and log[0] == 1 and log[-1] == 500
342
+ assert not errors
343
+
344
+
345
+ def test_recurring_misconceptions_need_two_recent_and_rank_by_spread(browser, server):
346
+ page, errors = page_with(browser, server)
347
+ now = datetime.now(timezone.utc)
348
+ p = patterns_of(page, slips_at(now, (20, "t_ext", "sign_mid"), (3, "t_ext", "sign_mid"), (2, "t_ext", "ext_partial"),
349
+ (40, "t_ext", "move_sign"), (35, "t_ext", "move_sign")))
350
+ assert [x["m"] for x in p["recurring"]] == ["sign_mid"] # one ext_partial is not a pattern; move_sign is old
351
+ p = patterns_of(page, slips_at(now, *[(1, "t_ext_eq", "move_sign")] * 4,
352
+ (1, "t_ext", "sign_mid"), (2, "t_simplify", "sign_mid"), (3, "t_ext_eq", "sign_mid")))
353
+ assert [x["m"] for x in p["recurring"]] == ["sign_mid", "move_sign"] # three skills before four times in one
354
+ assert p["recurring"][0]["n"] == 3 and len(p["recurring"][0]["skills"]) == 3
355
+ assert not errors
356
+
357
+
358
+ def test_the_skill_to_practise_is_the_back_skill_or_the_most_frequent(browser, server):
359
+ page, errors = page_with(browser, server)
360
+ now = datetime.now(timezone.utc)
361
+ mistakes = {"ext_partial": {"back": "t_ext"}, "sign_mid": {"back": None}}
362
+ p = patterns_of(page, slips_at(now, (1, "t_simplify", "ext_partial"), (2, "t_simplify", "ext_partial"),
363
+ (1, "t_simplify", "sign_mid"), (2, "t_simplify", "sign_mid"), (3, "t_ext", "sign_mid")), mistakes)
364
+ practise = {x["m"]: x["practise"] for x in p["recurring"]}
365
+ assert practise == {"ext_partial": "t_ext", "sign_mid": "t_simplify"}
366
+ assert not errors
367
+
368
+
369
+ def test_a_pattern_that_stopped_is_shown_as_stopped_until_60_days(browser, server):
370
+ page, errors = page_with(browser, server)
371
+ now = datetime.now(timezone.utc)
372
+ p = patterns_of(page, slips_at(now, *[(20, "t_ext", "sign_mid")] * 3, (70, "t_ext", "move_sign"), (75, "t_ext", "move_sign"),
373
+ (20, "t_ext", "ext_partial")))
374
+ assert p["recurring"] == [] and [x["m"] for x in p["stopped"]] == ["sign_mid"] # once is not a pattern; 70 days is gone
375
+ assert not errors
376
+
377
+
378
+ def test_a_wrong_review_answer_is_logged(browser, server):
379
+ old = (datetime.now(timezone.utc) - timedelta(days=5)).isoformat()
380
+ page, errors = page_with(browser, server, {"done": {"t_ext": old}, "seen": {"t_ext": old}}, no_chance=True)
381
+ page.locator(".mx-review").click()
382
+ page.locator(".mx-review-sheet .mx-choices button:not([data-right])").first.click()
383
+ playwright.expect(page.locator(".mx-review-sheet .mx-bad")).to_be_visible()
384
+ slips = progress_of(page)["slips"]
385
+ assert [(s["id"], s["via"]) for s in slips] == [("t_ext", "review")] and slips[0]["m"]
386
+ assert not errors
387
+
388
+
389
+ def test_a_practice_exam_logs_only_its_final_picks_when_handed_in(browser, server):
390
+ page, errors = page_with(browser, server, now=datetime(2026, 11, 3, 10))
391
+ page.locator(".mx-quiz-card").click()
392
+ sheet = page.locator(".mx-quiz-sheet")
393
+ sheet.locator("[data-act=quiz-start]").click()
394
+ sheet.locator(".mx-choices button:not([data-right])").first.click() # wrong, then changed to right
395
+ sheet.locator(".mx-choices button[data-right]").first.click()
396
+ for i in range(1, 12):
397
+ sheet.locator(f'[data-quiz-go="{i}"]').click()
398
+ sheet.locator(f".mx-choices button{':not([data-right])' if i in (1, 2) else '[data-right]'}").first.click()
399
+ assert "slips" not in progress_of(page) or not progress_of(page)["slips"] # nothing while the exam goes on
400
+ sheet.locator("[data-act=quiz-submit]").click()
401
+ playwright.expect(sheet.locator("h3")).to_have_text("10 ืžืชื•ืš 12 ื ื›ื•ื ื•ืช")
402
+ assert [s["via"] for s in progress_of(page)["slips"]] == ["exam", "exam"]
403
+ assert not errors
404
+
405
+
406
+ def test_a_left_practice_exam_logs_nothing(browser, server):
407
+ page, errors = page_with(browser, server, now=datetime(2026, 11, 3, 10))
408
+ page.locator(".mx-quiz-card").click()
409
+ page.locator("[data-act=quiz-start]").click()
410
+ page.locator(".mx-choices button:not([data-right])").first.click()
411
+ page.locator("[data-act=quiz-quit]").click()
412
+ page.locator("[data-act=quiz-quit]").click()
413
+ playwright.expect(page.locator(".mx-quiz-sheet")).to_be_hidden()
414
+ assert not progress_of(page).get("slips")
415
+ assert not errors
416
+
417
+
418
+ def test_no_patterns_card_without_a_pattern(browser, server):
419
+ now = datetime.now(timezone.utc)
420
+ page, errors = page_with(browser, server, {"slips": slips_at(now, (1, "t_ext", "sign_mid"))})
421
+ playwright.expect(page.locator(".mx-patterns")).to_have_count(0) # once is not a pattern
422
+ assert not errors
423
+
424
+
425
+ def test_the_patterns_card_names_the_top_mistake_and_opens_what_to_practise(browser, server):
426
+ now = datetime.now(timezone.utc)
427
+ slips = slips_at(now, (1, "t_simplify", "sign_mid"), (2, "t_ext_eq", "sign_mid"), (3, "t_simplify", "sign_mid"),
428
+ (1, "t_simplify", "ext_partial"), (4, "t_simplify", "ext_partial"))
429
+ page, errors = page_with(browser, server, {"slips": slips}, no_chance=True)
430
+ card = page.locator(".mx-patterns")
431
+ playwright.expect(card).to_contain_text("ื˜ืขื•ืช ืกื™ืžืŸ ื‘ืื™ื‘ืจ ื”ืืžืฆืขื™") # in two skills, before one skill
432
+ playwright.expect(card).to_contain_text("ื‘ึพ2 ืžื™ื•ืžื ื•ื™ื•ืช")
433
+ card.click()
434
+ sheet = page.locator(".mx-patterns-sheet")
435
+ playwright.expect(sheet.locator(".mx-pattern-list li")).to_have_count(2)
436
+ playwright.expect(sheet.locator(".mx-pattern-list li").first).to_contain_text("3 ืคืขืžื™ื ื‘ึพ2 ืžื™ื•ืžื ื•ื™ื•ืช ยท ืœืื—ืจื•ื ื” ืืชืžื•ืœ")
437
+ assert sheet.locator(".mx-pattern-list button[data-id]").evaluate_all("els=>els.map(e=>e.dataset.id)") == [
438
+ "t_simplify", "t_ext"] # most frequent skill; then ext_partial's back-skill
439
+ sheet.locator('.mx-pattern-list button[data-id="t_ext"]').click()
440
+ playwright.expect(page.locator(".mx-choices")).to_be_visible() # the practice of t_ext is open
441
+ playwright.expect(page.locator("#mxTitle")).to_contain_text("ืคื™ืœื•ื’")
442
+ assert not errors
443
+
444
+
445
+ def test_a_stopped_pattern_is_shown_as_a_win(browser, server):
446
+ now = datetime.now(timezone.utc)
447
+ page, errors = page_with(browser, server, {"slips": slips_at(now, *[(20, "t_ext", "sign_mid")] * 2)}, no_chance=True)
448
+ card = page.locator(".mx-patterns")
449
+ playwright.expect(card).to_contain_text("ื›ื‘ืจ ืœื ื—ื•ื–ืจ")
450
+ card.click()
451
+ sheet = page.locator(".mx-patterns-sheet")
452
+ playwright.expect(sheet).to_contain_text("ื›ืจื’ืข ืื™ืŸ ื˜ืขื•ืช ืฉื—ื•ื–ืจืช")
453
+ playwright.expect(sheet.locator(".mx-pattern-list.gone li")).to_contain_text("ื˜ืขื•ืช ืกื™ืžืŸ ื‘ืื™ื‘ืจ ื”ืืžืฆืขื™")
454
+ assert not errors
455
+
456
+
457
+ def test_the_patterns_sheet_fits_a_phone(browser, server):
458
+ now = datetime.now(timezone.utc)
459
+ slips = slips_at(now, *[(1, "t_simplify", "sign_mid")] * 2, *[(1, "t_ext", "ext_partial")] * 2,
460
+ *[(2, "t_ext_eq", "move_sign")] * 2, *[(20, "t_ext", "sign_product")] * 2)
461
+ page, errors = page_with(browser, server, {"slips": slips}, no_chance=True)
462
+ page.set_viewport_size({"width": 375, "height": 812})
463
+ for target in (".mx-patterns", None):
464
+ assert page.evaluate("document.documentElement.scrollWidth<=innerWidth")
465
+ if target:
466
+ page.locator(target).click()
467
+ playwright.expect(page.locator(".mx-patterns-sheet .mx-pattern-list li")).to_have_count(4)
468
+ assert not errors
469
+
470
+
471
+ def test_the_path_marks_the_skill_to_strengthen(browser, server):
472
+ now = datetime.now(timezone.utc)
473
+ progress = {"slips": slips_at(now, (1, "t_ext", "ext_partial"), (4, "t_simplify", "ext_partial")),
474
+ "done": {"t_ext": now.isoformat()}, "seen": {"t_ext": now.isoformat()}}
475
+ page, errors = page_with(browser, server, progress, no_chance=True)
476
+ assert page.locator(".mx-star.fix").evaluate_all("els=>els.map(e=>e.dataset.id)") == ["t_ext"] # ext_partial's back-skill
477
+ playwright.expect(page.locator('.mx-star[data-id="t_ext"] + .mx-lbl em')).to_have_text("ืœื—ื–ืง")
478
+ assert not errors
479
+
480
+
481
+ def test_a_mistake_that_is_a_pattern_says_so_where_it_happens(browser, server):
482
+ now = datetime.now(timezone.utc)
483
+ page, errors = page_with(browser, server, {"slips": slips_at(now, (3, "t_ext_eq", "ext_partial"))}, no_chance=True)
484
+ page.locator('.mx-star[data-id="t_simplify"]').click()
485
+ page.locator(".mx-choices button[data-m=ext_partial]").first.click() # the second time: now a pattern
486
+ note = page.locator(".mx-bad .mx-pattern-note")
487
+ playwright.expect(note).to_contain_text("2 ืคืขืžื™ื ื‘ึพ2 ืžื™ื•ืžื ื•ื™ื•ืช")
488
+ playwright.expect(page.locator('.mx-bad button[data-id="t_ext"]')).to_have_count(1) # offered once, not twice
489
+ assert not errors
490
+
491
+
492
+ def test_a_first_mistake_has_no_pattern_note(browser, server):
493
+ page, errors = page_with(browser, server, no_chance=True)
494
+ page.locator('.mx-star[data-id="t_simplify"]').click()
495
+ page.locator(".mx-choices button:not([data-right])").first.click()
496
+ playwright.expect(page.locator(".mx-bad")).to_be_visible()
497
+ playwright.expect(page.locator(".mx-pattern-note")).to_have_count(0)
498
+ assert not errors
tests/test_unit_quiz.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The summarizing quiz at the end of a unit: its content, its API, and the paper quiz in a browser.
2
+ Answers are kept in real PostgreSQL (scan_support); the AI check is a stand-in."""
3
+ import asyncio
4
+ import copy
5
+ import json
6
+ import socket
7
+ import threading
8
+ import time
9
+
10
+ import uvicorn
11
+
12
+ import httpx
13
+ import pytest
14
+
15
+ from app.answer_grading import validate_assessment
16
+ from app.main import create_app
17
+ from app.math_skills import algebra9, practice_graph, problems, question_version, quiz_snapshot
18
+ from scan_support import OWNER, OTHER, ScanAuth, answer_store, postgres_server
19
+ from test_answer_grading import assessment
20
+ from test_answer_scans import png
21
+
22
+ QUIZ_UNITS = ["t", "t2", "k", "i"]
23
+
24
+
25
+ @pytest.fixture(autouse=True)
26
+ def unlocked_open_ended(monkeypatch):
27
+ monkeypatch.setenv("MATHLY_OPEN_ENDED_LOCKED", "0")
28
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
29
+
30
+
31
+ def test_the_units_taught_first_end_with_a_quiz_worth_100():
32
+ units = {u["id"]: u for u in algebra9()["units"]}
33
+ for uid in QUIZ_UNITS:
34
+ quiz = units[uid]["quiz"]
35
+ assert quiz["minutes"] == 45 and sum(q["points"] for q in quiz["questions"]) == 100
36
+ assert all("choices" not in q for q in quiz["questions"])
37
+
38
+
39
+ def test_a_broken_quiz_is_named():
40
+ graph = copy.deepcopy(algebra9())
41
+ t = next(u for u in graph["units"] if u["id"] == "t")
42
+ t["quiz"]["questions"][0]["points"] = 30
43
+ t["quiz"]["minutes"] = 0
44
+ k = next(u for u in graph["units"] if u["id"] == "k")
45
+ k["quiz"]["questions"][0]["figure"] = "A=0,0; B=1,0; poly A B Z"
46
+ found = problems(graph)
47
+ assert "t quiz: minutes must be a whole number from 5 to 120" in found
48
+ assert "k quiz question 1: figure names unknown point Z" in found
49
+ t["quiz"]["minutes"] = 45
50
+ assert "t quiz: points add up to 105, not 100" in problems(graph)
51
+
52
+
53
+ def test_the_quiz_is_open_only_with_answer_storage_and_grading():
54
+ units = lambda g: {u["id"]: u for u in g["units"]}
55
+ assert units(practice_graph(True, False))["t"]["quiz"]["locked"] is False
56
+ assert units(practice_graph(False, False))["t"]["quiz"]["locked"] is True
57
+ assert units(practice_graph(True, True))["t"]["quiz"]["locked"] is True
58
+ q = units(practice_graph(True, False))["k"]["quiz"]["questions"][1]
59
+ assert q["question_version"] == question_version(quiz_snapshot(units(algebra9())["k"], 1))
60
+
61
+
62
+ def test_quiz_answers_are_kept_and_checked_only_when_asked(answer_store, monkeypatch):
63
+ store = answer_store
64
+ app = create_app(auth=ScanAuth()); app.state.answers = store
65
+ calls = []
66
+
67
+ class Grader:
68
+ def grade(self, image, snapshot):
69
+ calls.append(snapshot)
70
+ return validate_assessment(assessment(score=100))
71
+ app.state.answer_grader = Grader()
72
+ t = next(u for u in algebra9()["units"] if u["id"] == "t")
73
+ version = question_version(quiz_snapshot(t, 2))
74
+
75
+ async def run():
76
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test",
77
+ headers={"Authorization": f"Bearer {OWNER}"}) as c:
78
+ url = "/api/math/quiz-attempts"
79
+ assert (await c.post(url, json={"unit": "t", "index": 9, "question_version": version})).status_code == 404
80
+ assert (await c.post(url, json={"unit": "q", "index": 0, "question_version": version})).status_code == 404
81
+ changed = await c.post(url, json={"unit": "t", "index": 2, "question_version": "old"})
82
+ assert (changed.status_code, changed.json()["detail"]) == (409, "question_changed")
83
+ first = (await c.post(url, json={"unit": "t", "index": 2, "question_version": version, "new": True})).json()
84
+ aid = first["attempt"]["id"]
85
+ assert first["attempt"]["question_id"] == "quiz:t:2"
86
+ assert first["attempt"]["snapshot"]["points"] == 25 and "ื‘ื•ื—ืŸ ืžืกื›ื" in first["attempt"]["snapshot"]["name"]
87
+ again = (await c.post(url, json={"unit": "t", "index": 2, "question_version": version})).json()
88
+ assert again["attempt"]["id"] == aid # the sitting's attempt continues
89
+ other = await c.get(f"/api/math/answer-attempts/{aid}", headers={"Authorization": f"Bearer {OTHER}"})
90
+ assert other.status_code == 404
91
+ # the same steps as the page: request, confirm, lease, upload
92
+ scan = (await c.post(f"/api/math/answer-attempts/{aid}/scans", json={"renew": True})).json()["scan"]
93
+ await c.post(f"/api/math/answer-scans/{scan['id']}/confirm")
94
+ lease = (await c.post(f"/api/math/answer-scans/{scan['id']}/uploads", json={"key": "9f0d6c3e-8c51-4a51-9c7e-0d5f4b8a1e11"})).json()
95
+ up = await c.post(f"/api/math/answer-scans/{scan['id']}/uploads/{lease['lease_id']}",
96
+ files={"photo": ("a.png", png(), "image/png")})
97
+ assert up.status_code == 200 and up.json()["attachment"]
98
+ await asyncio.sleep(.2)
99
+ assert not calls and up.json()["grading"] is None # not checked on upload
100
+ graded = (await c.post(f"/api/math/answer-attempts/{aid}/grade", json={})).json()
101
+ assert graded["grading"]["result"]["score"] == 100
102
+ assert calls == [first["attempt"]["snapshot"]]
103
+ monkeypatch.setenv("MATHLY_OPEN_ENDED_LOCKED", "1")
104
+ locked = await c.post(url, json={"unit": "t", "index": 2, "question_version": version})
105
+ assert (locked.status_code, locked.json()["detail"]) == (403, "open_ended_locked")
106
+ asyncio.run(run())
107
+
108
+
109
+ playwright = pytest.importorskip("playwright.sync_api")
110
+
111
+
112
+ @pytest.fixture
113
+ def quiz_server(answer_store):
114
+ """The app with real answer storage, and an AI check that scores by question: 100, 60, 100, ..."""
115
+ app = create_app(auth=ScanAuth()); app.state.answers = answer_store
116
+ calls = []
117
+
118
+ class Grader:
119
+ def grade(self, image, snapshot):
120
+ calls.append(snapshot["name"])
121
+ return validate_assessment(assessment(score=60, reasoning_correct=False) if snapshot["name"].endswith("2")
122
+ else assessment(score=100))
123
+ app.state.answer_grader = Grader()
124
+ with socket.socket() as sock:
125
+ sock.bind(("127.0.0.1", 0))
126
+ server = uvicorn.Server(uvicorn.Config(app, log_level="error"))
127
+ thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
128
+ thread.start()
129
+ try:
130
+ while not server.started:
131
+ time.sleep(.01)
132
+ yield f"http://127.0.0.1:{sock.getsockname()[1]}", calls
133
+ finally:
134
+ server.should_exit = True; thread.join(timeout=5)
135
+
136
+
137
+ def signed_in(p, url, mobile=True):
138
+ from test_answer_browser import session
139
+ browser = p.chromium.launch()
140
+ page = session(browser, mobile=mobile).new_page()
141
+ errors = []
142
+ page.on("pageerror", lambda e: errors.append(str(e)))
143
+ page.goto(url)
144
+ page.locator('.mx-nav [data-view="path"]').click()
145
+ return browser, page, errors
146
+
147
+
148
+ def photograph(page, i, tmp_path):
149
+ f = tmp_path / f"answer{i}.png"
150
+ f.write_bytes(png())
151
+ with page.expect_file_chooser() as chooser:
152
+ page.locator(f'[data-uq-photo="{i}"]').click()
153
+ chooser.value.set_files(str(f))
154
+ playwright.expect(page.locator(f'[data-uq-i="{i}"] [role=status]')).to_have_text("โœ“ ื”ืชืฉื•ื‘ื” ื ืฉืžืจื”", timeout=10000)
155
+
156
+
157
+ def test_a_unit_quiz_is_written_photographed_handed_in_and_then_checked(quiz_server, tmp_path):
158
+ url, calls = quiz_server
159
+ with playwright.sync_playwright() as p:
160
+ browser, page, errors = signed_in(p, url)
161
+ try:
162
+ page.locator('.mx-uquiz[data-uquiz="t"]').click()
163
+ playwright.expect(page.locator(".mx-uq-sheet h3")).to_have_text("ื‘ื•ื—ืŸ ืžืกื›ื")
164
+ page.locator("[data-act=uq-start]").click()
165
+ paper = page.locator(".mx-uq-paper")
166
+ playwright.expect(paper.locator(".mx-uq-q")).to_have_count(4)
167
+ playwright.expect(paper.locator(".mx-uq-clock")).to_have_text(__import__("re").compile(r"^4[45]:\d\d$"))
168
+ for i in range(3): # the last question is left without an answer
169
+ photograph(page, i, tmp_path)
170
+ photograph(page, 1, tmp_path) # a retake replaces the answer
171
+ assert not calls # nothing is checked before handing in
172
+ paper.locator("[data-act=uq-submit]").click()
173
+ playwright.expect(paper.locator(".mx-quiz-warn")).to_contain_text("0 ื ืงื•ื“ื•ืช")
174
+ paper.locator("[data-act=uq-submit]").click()
175
+ sheet = page.locator(".mx-uq-sheet")
176
+ playwright.expect(sheet.locator("h3")).to_have_text("ื‘ื•ื—ืŸ ืžืกื›ื: 65/100", timeout=20000) # 25 + 15 + 25 + 0
177
+ playwright.expect(sheet.locator(".mx-uq-verdict")).to_have_text(["ื ื›ื•ืŸ ื•ืžืœื", "ื ื›ื•ืŸ ื‘ื—ืœืงื•", "ื ื›ื•ืŸ ื•ืžืœื", "ืœื ืฆื•ืœืžื” ืชืฉื•ื‘ื”"])
178
+ playwright.expect(sheet.locator(".mx-uq-qhead span")).to_have_text(["25/25 ื ืงืณ", "15/25 ื ืงืณ", "25/25 ื ืงืณ", "0/25 ื ืงืณ"])
179
+ assert len(calls) == 3
180
+ page.locator(".mx-uq-sheet .mx-x").click()
181
+ playwright.expect(page.locator('.mx-uquiz[data-uquiz="t"]')).to_have_attribute("aria-label", "ื‘ื•ื—ืŸ ืžืกื›ื: ื˜ื›ื ื™ืงื” ืืœื’ื‘ืจื™ืช 1, 65/100")
182
+ page.reload() # the result stays, and opening it again does not check again
183
+ page.locator('.mx-nav [data-view="path"]').click()
184
+ page.locator('.mx-uquiz[data-uquiz="t"]').click()
185
+ page.locator("[data-uq-result]").first.click()
186
+ playwright.expect(page.locator(".mx-uq-sheet h3")).to_have_text("ื‘ื•ื—ืŸ ืžืกื›ื: 65/100", timeout=10000)
187
+ assert len(calls) == 3 and not errors
188
+ finally:
189
+ browser.close()
190
+
191
+
192
+ def test_when_the_time_and_the_photo_minutes_are_over_the_quiz_is_handed_in(quiz_server, tmp_path):
193
+ url, calls = quiz_server
194
+ with playwright.sync_playwright() as p:
195
+ browser, page, errors = signed_in(p, url)
196
+ try:
197
+ page.locator('.mx-uquiz[data-uquiz="k"]').click()
198
+ page.locator("[data-act=uq-start]").click()
199
+ photograph(page, 0, tmp_path)
200
+ page.locator(".mx-uq-paper .mx-x").click() # leaving does not stop the clock
201
+ playwright.expect(page.locator('.mx-uquiz[data-uquiz="k"]')).to_have_attribute("aria-label", "ื‘ื•ื—ืŸ ืžืกื›ื: ื“ืœืชื•ืŸ, ื‘ืชื”ืœื™ืš")
202
+ page.evaluate("""()=>{ const k=Object.keys(localStorage).find(k=>k.startsWith('mathly.progress.v1:'));
203
+ const P=JSON.parse(localStorage.getItem(k)); P.uq.k.sitting.started-=50*60000; localStorage.setItem(k,JSON.stringify(P)); }""")
204
+ page.reload()
205
+ page.locator('.mx-nav [data-view="path"]').click()
206
+ page.locator('.mx-uquiz[data-uquiz="k"]').click() # 50 minutes in: the photo minutes are running
207
+ playwright.expect(page.locator(".mx-uq-banner")).to_contain_text("ื”ื–ืžืŸ ืœื›ืชื™ื‘ื” ื ื’ืžืจ")
208
+ page.locator(".mx-uq-paper .mx-x").click()
209
+ page.evaluate("""()=>{ const k=Object.keys(localStorage).find(k=>k.startsWith('mathly.progress.v1:'));
210
+ const P=JSON.parse(localStorage.getItem(k)); P.uq.k.sitting.started-=10*60000; localStorage.setItem(k,JSON.stringify(P)); }""")
211
+ page.reload()
212
+ page.locator('.mx-nav [data-view="path"]').click()
213
+ page.locator('.mx-uquiz[data-uquiz="k"]').click() # over: handed in as it is, and checked
214
+ playwright.expect(page.locator(".mx-uq-sheet h3")).to_have_text("ื‘ื•ื—ืŸ ืžืกื›ื: 20/100", timeout=20000)
215
+ playwright.expect(page.locator(".mx-uq-sheet .mx-src")).to_contain_text("ื”ื•ื’ืฉ ื›ืฉื”ื–ืžืŸ ื ื’ืžืจ")
216
+ assert len(calls) == 1 and not errors
217
+ finally:
218
+ browser.close()