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 +25 -2
- app/answer_scans.py +9 -0
- app/math_algebra9.json +96 -4
- app/math_skills.py +46 -0
- openspec/changes/add-misconception-patterns/.openspec.yaml +2 -0
- openspec/changes/add-misconception-patterns/design.md +51 -0
- openspec/changes/add-misconception-patterns/proposal.md +34 -0
- openspec/changes/add-misconception-patterns/specs/misconception-patterns/spec.md +111 -0
- openspec/changes/add-misconception-patterns/tasks.md +29 -0
- static/answer-scans.js +148 -56
- static/math-sky.js +38 -3
- static/math.css +120 -15
- static/math.js +485 -41
- supabase/migrations/20261103120000_math_answer_scan_decline.sql +122 -0
- tests/scan_support.py +3 -1
- tests/test_answer_browser.py +68 -28
- tests/test_sky_browser.py +244 -1
- tests/test_unit_quiz.py +218 -0
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 |
-
|
|
|
|
|
|
|
| 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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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=
|
|
|
|
| 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
|
| 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 |
-
//
|
| 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=
|
| 43 |
try{
|
| 44 |
const list=await json("/api/math/answer-scans");if(!live(e)||!inbox)return;
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 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 |
-
|
| 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)?"
|
| 90 |
if(!received){
|
| 91 |
-
const b=q.node.querySelector("[data-request]");b.disabled=
|
| 92 |
b.onclick=async()=>{
|
| 93 |
b.disabled=true;
|
| 94 |
-
try{
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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","
|
| 158 |
-
node.innerHTML=`<div class="mx-scan-card
|
| 159 |
-
<
|
| 160 |
-
<p
|
| 161 |
-
<
|
| 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>ื
|
| 167 |
-
<
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
| 174 |
node.onkeydown=event=>{
|
| 175 |
-
if(event.key==="Escape"
|
| 176 |
-
|
| 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 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
};
|
| 190 |
-
|
| 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=()=>{
|
| 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;
|
|
|
|
| 225 |
catch(error){say(error.message);}
|
| 226 |
finally{if(valid())controls(false);}
|
| 227 |
};
|
| 228 |
if(result.scan.state==="expired")expired();
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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&¤t(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=>({"&":"&","<":"<",">":"&
|
|
| 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
|
| 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 |
-
|
| 140 |
-
|
| 141 |
-
.mx-scan-overlay{position:fixed;inset:0;z-index:1000;background:#000c;overflow:auto;padding:20px;display:flex;align-items:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
/*
|
| 406 |
-
.mx-trail{position:relative;display:flex;flex-direction:column;align-items:center;gap:
|
| 407 |
-
.mx-
|
| 408 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
.mx-uhead{display:flex;align-items:center;gap:4px 10px;flex-wrap:wrap}
|
| 416 |
-
.mx-
|
| 417 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 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:
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
function pathView(){
|
| 267 |
const nxt=next(), cls=MY_CLASS?` ยท ืืืชืช ${esc(MY_CLASS.name)}`:"", teach=can("students.view");
|
| 268 |
-
|
| 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
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
| 275 |
const res=teach?` data-act="resources" data-unit="${u.id}"`:""; // a teacher opens the lesson's resources from its title
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 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)
|
|
|
|
|
|
|
| 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>"}
|
|
|
|
| 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('*
|
|
|
|
|
|
|
| 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 |
-
|
| 77 |
-
phone.locator('
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
| 81 |
phone.locator('[data-close]').click()
|
| 82 |
-
|
| 83 |
-
phone
|
| 84 |
-
|
| 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(
|
| 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('
|
| 116 |
-
|
| 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 |
-
|
|
|
|
| 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.
|
| 148 |
-
playwright.expect(phone.locator('
|
| 149 |
-
|
| 150 |
-
playwright.expect(phone.locator('
|
| 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('
|
| 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('
|
| 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()
|