wzender commited on
Commit
72ba2ce
·
1 Parent(s): c5754b1

feat: implement lesson rounds with instant feedback and star system

Browse files

- Added a new lesson round feature that presents 10 questions from a lesson's skills, providing instant feedback for each answer.
- Implemented a star rating system based on performance in the lesson round, with conditions for earning 0 to 3 stars.
- Updated the home interface to display a map of lessons, showing progress and allowing easy access to the next lesson.
- Created database migration to accommodate the new lesson round functionality.
- Developed comprehensive tests for the lesson rounds and their integration with the home interface.

docs/tasks/prd-mathly.md CHANGED
@@ -400,9 +400,10 @@ The exercise screen is where a student practices one skill.
400
 
401
  ### 4.16 Student screens (STU)
402
 
403
- A tab bar at the bottom of the screen leads between them: home, path, submissions (STU-7), grades, collection and review (STU-8).
404
 
405
- - **STU-1 Home** shows only what is critical: the nearest test's reminder with its readiness, today's task (TST-3) and **its practice tests below the pass line** (taken and with a best grade below 87, i.e. 13 of 15: each a button to take it again with the best grade as a chip inside it; one not taken yet, or at 87 or more, is not listed; other tests' practice tests are on their own cards), a required simulation, **every lesson behind the curriculum** (PTH-11; earliest date first, each kept short: its name, its date, a link named just "בוחן" with the best grade as a chip inside the link, a bare number or "-" when there is none; PTH-10), the **new** assignments (המשימות שלי: not handed in yet), and a button that continues the path at the next skill; my AI requests and their state (AI-1).
 
406
  - **STU-2 Path:** see 4.4.
407
  - **STU-3 Grades:** all quiz (best grade), exercise test, simulation and assignment grades.
408
  - **STU-4 Collection:** see MST-7.
 
400
 
401
  ### 4.16 Student screens (STU)
402
 
403
+ A tab bar at the bottom of the screen leads between them: home, path (named "העמקה": going deeper, skill by skill), submissions (STU-7), grades, collection and review (STU-8).
404
 
405
+ - **STU-1 Home** is a game board. On top: the stars collected (three per lesson), the lessons perfected, the daily goal and streak, and one big **play** button for the next lesson (the one with a round in progress, else the earliest behind the curriculum, else the first with fewer than three stars). Below it, a **map of the lessons** on a winding trail: each lesson a node with its number (a crown when perfect), its name, its stars (★ a lesson round finished, ★★ a round with 8 of 10 or more, ★★★ every skill of the lesson mastered), "⏰" when it is behind the curriculum (PTH-11) and "◐" when a round is open. Tapping a node opens the lesson's round (STU-9). Then the nearest test's reminder with its readiness, today's task (TST-3) and its practice tests below the pass line (taken and with a best grade below 87, i.e. 13 of 15, each a button to take it again with the best grade as a chip inside it), a required simulation, the **new** assignments (המשימות שלי: not handed in yet), my AI requests and their state (AI-1), and a link to the path for going deeper ("להעמקה").
406
+ - **STU-9 Lesson round (סבב שיעור).** At most 10 questions from the lesson's skills that are not mastered, at most 3 from each (a lesson with one skill left is a round of 3); a lesson with every skill mastered draws 10 from all of them. Instant feedback as on the exercise screen (EX); every answer is a practice answer of its skill (mastery, daily goal, review list). The result shows the score, the lesson's stars, the skills mastered in the round and those one good day away, and each mistake's name and tip; never the right answers.
407
  - **STU-2 Path:** see 4.4.
408
  - **STU-3 Grades:** all quiz (best grade), exercise test, simulation and assignment grades.
409
  - **STU-4 Collection:** see MST-7.
mathly2/main.py CHANGED
@@ -15,7 +15,7 @@ from .access import RoleResolver, learner, permissions_of
15
  from .auth import DevAuthenticator, SupabaseAuthenticator, current_user
16
  from .bank import Bank, load_bank
17
 
18
- from . import accounts, ai, ai_help, assignments, classes, cleanup, content, progress, quizzes, reads, realtests, review, skills, status, subjects, worksheets, written
19
  from .config import Settings, load_settings
20
  from .cached import CachedStore
21
  from .store import InMemoryStore, StoreUnavailable, SupabaseStore
@@ -83,6 +83,7 @@ def create_app(settings: Settings | None = None, bank: Bank | None = None, store
83
  app.include_router(classes.curriculum)
84
  app.include_router(accounts.router)
85
  app.include_router(skills.router)
 
86
  app.include_router(quizzes.router)
87
  app.include_router(realtests.router)
88
  app.include_router(progress.router)
 
15
  from .auth import DevAuthenticator, SupabaseAuthenticator, current_user
16
  from .bank import Bank, load_bank
17
 
18
+ from . import accounts, ai, ai_help, assignments, classes, cleanup, content, progress, quizzes, reads, realtests, review, rounds, skills, status, subjects, worksheets, written
19
  from .config import Settings, load_settings
20
  from .cached import CachedStore
21
  from .store import InMemoryStore, StoreUnavailable, SupabaseStore
 
83
  app.include_router(classes.curriculum)
84
  app.include_router(accounts.router)
85
  app.include_router(skills.router)
86
+ app.include_router(rounds.router)
87
  app.include_router(quizzes.router)
88
  app.include_router(realtests.router)
89
  app.include_router(progress.router)
mathly2/quiz.py CHANGED
@@ -7,12 +7,14 @@ from . import attempts
7
  from .attempts import QUIZ_SIZE
8
 
9
 
10
- def draw(bank, lesson_id: str, used: dict[str, int] | None = None, rng: random.Random | None = None, size: int = QUIZ_SIZE) -> list[dict]:
 
11
  """[{skill_id, key}] in a shuffled order. Skills without questions are left out. Each skill gets `size // k` questions and the
12
  remainder goes to distinct skills picked at random, so quotas differ by at most one. Within a skill the least-used questions come
13
- first (`used`: how often the student had each question key in earlier quizzes of this lesson), ties in random order."""
 
14
  used, rng = used or {}, rng or random.Random()
15
- skills = [s for s in bank.skills if s["unit"] == lesson_id and bank.question_keys(s["id"])]
16
  if not skills:
17
  return []
18
  quota = {s["id"]: size // len(skills) for s in skills}
 
7
  from .attempts import QUIZ_SIZE
8
 
9
 
10
+ def draw(bank, lesson_id: str, used: dict[str, int] | None = None, rng: random.Random | None = None, size: int = QUIZ_SIZE,
11
+ skill_ids: set[str] | None = None) -> list[dict]:
12
  """[{skill_id, key}] in a shuffled order. Skills without questions are left out. Each skill gets `size // k` questions and the
13
  remainder goes to distinct skills picked at random, so quotas differ by at most one. Within a skill the least-used questions come
14
+ first (`used`: how often the student had each question key in earlier quizzes of this lesson), ties in random order. `skill_ids`: draw
15
+ only from these skills of the lesson (a lesson round leaves out the mastered ones)."""
16
  used, rng = used or {}, rng or random.Random()
17
+ skills = [s for s in bank.skills if s["unit"] == lesson_id and bank.question_keys(s["id"]) and (skill_ids is None or s["id"] in skill_ids)]
18
  if not skills:
19
  return []
20
  quota = {s["id"]: size // len(skills) for s in skills}
mathly2/realtests.py CHANGED
@@ -274,9 +274,10 @@ def home(request: Request, user: dict = Depends(learner), subject: str = Query("
274
  the required simulation, and the next skill of the path."""
275
  here, today, uid = reads.of(request), clock.school_day(), user["id"]
276
  class_ids = [c["id"] for c in here.classes_of_student(user["email"])]
277
- from .skills import RECENT, behind_lessons, subject_param
 
278
  subject = subject_param(request, user, subject)
279
- here.prefetch(("tests_of_classes", class_ids), ("recent_skills", uid, RECENT), ("attempts_of", uid, "quiz"), ("curriculum",), *_student_reads(uid)) # the tests and the student's facts: one wait
280
  tests = [t for t in here.tests_of_classes(class_ids) if t["date"] >= today and t.get("subject_id", "math") == subject]
281
  cards = [card(request, user["id"], t, today) for t in tests]
282
  nearest = cards[0] if cards else None
@@ -285,7 +286,7 @@ def home(request: Request, user: dict = Depends(learner), subject: str = Query("
285
  return {"tests": cards, "task": {"test_id": nearest["id"], **_with_skill_name(request, nearest["task"])} if nearest else None,
286
  "required_simulations": [{"test_id": c["id"], "date": c["date"], "days_left": c["days_left"]} for c in cards if c["simulation"]["required"]],
287
  "next_skill": {"id": next_id, "name": next_skill_row["name"]} if next_skill_row else None,
288
- "behind": behind_lessons(request, user, subject), "subject": subject,
289
  "weak_tests": [{"test_id": c["id"], "date": c["date"], "days_left": c["days_left"], "number": e["number"], "best": e["best"], "open": e["open"]}
290
  for c in cards[:1] for e in c["exercise_tests"] if e["best"] is not None and e["best"] < attempts.PASS_GRADE]} # the nearest test's card only
291
 
 
274
  the required simulation, and the next skill of the path."""
275
  here, today, uid = reads.of(request), clock.school_day(), user["id"]
276
  class_ids = [c["id"] for c in here.classes_of_student(user["email"])]
277
+ from . import rounds
278
+ from .skills import RECENT, behind_lessons, lesson_map, subject_param
279
  subject = subject_param(request, user, subject)
280
+ here.prefetch(("tests_of_classes", class_ids), ("recent_skills", uid, RECENT), ("attempts_of", uid, "quiz"), ("attempts_of", uid, rounds.KIND), ("curriculum",), ("daily_correct", uid), *_student_reads(uid)) # the tests and the student's facts: one wait
281
  tests = [t for t in here.tests_of_classes(class_ids) if t["date"] >= today and t.get("subject_id", "math") == subject]
282
  cards = [card(request, user["id"], t, today) for t in tests]
283
  nearest = cards[0] if cards else None
 
286
  return {"tests": cards, "task": {"test_id": nearest["id"], **_with_skill_name(request, nearest["task"])} if nearest else None,
287
  "required_simulations": [{"test_id": c["id"], "date": c["date"], "days_left": c["days_left"]} for c in cards if c["simulation"]["required"]],
288
  "next_skill": {"id": next_id, "name": next_skill_row["name"]} if next_skill_row else None,
289
+ "behind": behind_lessons(request, user, subject), "subject": subject, "map": lesson_map(request, user, subject),
290
  "weak_tests": [{"test_id": c["id"], "date": c["date"], "days_left": c["days_left"], "number": e["number"], "best": e["best"], "open": e["open"]}
291
  for c in cards[:1] for e in c["exercise_tests"] if e["best"] is not None and e["best"] < attempts.PASS_GRADE]} # the nearest test's card only
292
 
mathly2/rounds.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lesson rounds (סבב שיעור): a short round of a lesson's skills with instant feedback, played from the home's map.
2
+
3
+ A round is an m2_attempts row of kind `lesson_round` (scope: the lesson id). It draws from the lesson's skills that are not mastered, at most
4
+ PER_SKILL questions from each and ROUND_SIZE in all, so it gets shorter as the lesson fills up; a lesson whose skills are all mastered draws
5
+ ROUND_SIZE from all of them. The questions are answered in order, each once, and every answer is also a practice answer of its skill (it counts
6
+ for mastery and the daily goal; a wrong one goes to the review list). After the last answer the round is graded and closed.
7
+
8
+ A lesson's stars: 3 when every skill is mastered, else 2 when a finished round had at least TWO_STARS percent right, else 1 when a round was
9
+ finished, else 0. Nothing here ever says which choice was right."""
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from collections import Counter
14
+ from datetime import datetime, timedelta
15
+
16
+ from fastapi import APIRouter, Depends, HTTPException, Request
17
+ from pydantic import BaseModel
18
+
19
+ from . import attempts, clock, quiz, reads
20
+ from .access import learner
21
+ from .learning import MASTERED, record_practice_answer, skill_state
22
+
23
+ log = logging.getLogger("mathly2.rounds")
24
+ router = APIRouter(prefix="/api")
25
+
26
+ KIND = "lesson_round"
27
+ ROUND_SIZE = 10
28
+ PER_SKILL = 3 # three right in a row is what mastery needs, so a skill never takes more of a round
29
+ TWO_STARS = 80 # percent right in a finished round for the second star
30
+
31
+
32
+ class RoundAnswer(BaseModel):
33
+ key: str
34
+ choice: int
35
+
36
+
37
+ def stars(skill_states: list[str], best: int | None) -> int:
38
+ """The lesson's stars from its skills' states and the best grade of its finished rounds (None: no round finished)."""
39
+ if skill_states and all(s == MASTERED for s in skill_states):
40
+ return 3
41
+ if best is None:
42
+ return 0
43
+ return 2 if best >= TWO_STARS else 1
44
+
45
+
46
+ def facts(rows: list[dict]) -> dict[str, dict]:
47
+ """Per lesson with rounds: {best, finished, open} from the student's round attempts (oldest first)."""
48
+ found: dict[str, dict] = {}
49
+ for row in rows:
50
+ item = found.setdefault(row["scope"], {"best": None, "finished": 0, "open": None})
51
+ if attempts.is_open(row):
52
+ item["open"] = row["id"]
53
+ else:
54
+ item["finished"] += 1
55
+ item["best"] = row["grade"] if item["best"] is None else max(item["best"], row["grade"])
56
+ return found
57
+
58
+
59
+ def draw(bank, lesson_id: str, states: dict[str, str], used: dict[str, int] | None = None, rng=None) -> list[dict]:
60
+ """The questions of a new round: from the skills not mastered (all of them when every one is), the least-used first."""
61
+ skills = [s["id"] for s in bank.skills if s["unit"] == lesson_id and bank.question_keys(s["id"])]
62
+ left = {s for s in skills if states.get(s) != MASTERED}
63
+ if not left:
64
+ return quiz.draw(bank, lesson_id, used, rng, size=ROUND_SIZE)
65
+ return quiz.draw(bank, lesson_id, used, rng, size=min(ROUND_SIZE, PER_SKILL * len(left)), skill_ids=left)
66
+
67
+
68
+ def _lesson(request: Request, user: dict, lesson_id: str) -> dict:
69
+ from .skills import visible_lesson_ids
70
+ lesson = next((l for l in request.app.state.bank.lessons if l["id"] == lesson_id), None)
71
+ shown = visible_lesson_ids(request, user)
72
+ if lesson is None or (shown is not None and lesson_id not in shown):
73
+ raise HTTPException(404, "השיעור לא נמצא")
74
+ return lesson
75
+
76
+
77
+ def _graded(bank, row: dict) -> list[tuple[dict, bool, dict | None]]:
78
+ """The answered questions in order: (question ref, right, mistake)."""
79
+ out = []
80
+ for q in row["questions"]:
81
+ choice = row["answers"].get(q["key"])
82
+ if not isinstance(choice, int):
83
+ break
84
+ graded = bank.grade(q["skill_id"], q["key"], choice) or {"correct": False, "mistake": None}
85
+ out.append((q, graded["correct"], graded["mistake"]))
86
+ return out
87
+
88
+
89
+ def _when(value) -> datetime | None:
90
+ if value is None or isinstance(value, datetime):
91
+ return value
92
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
93
+
94
+
95
+ def _lesson_stars(request: Request, uid: str, lesson_id: str, rows: list[dict]) -> int:
96
+ bank, store = request.app.state.bank, request.app.state.store
97
+ states = {p["skill_id"]: skill_state(p) for p in store.progress_of(uid)}
98
+ lesson_skills = [s["id"] for s in bank.skills if s["unit"] == lesson_id]
99
+ return stars([states.get(s, "not_started") for s in lesson_skills], facts(rows).get(lesson_id, {}).get("best"))
100
+
101
+
102
+ def view(request: Request, row: dict) -> dict:
103
+ """An open round: its size, a result per answered question and the next question (without the right answer). A finished round: its result."""
104
+ bank, store = request.app.state.bank, request.app.state.store
105
+ lesson = next((l for l in bank.lessons if l["id"] == row["scope"]), {"id": row["scope"], "name": row["scope"]})
106
+ done = _graded(bank, row)
107
+ base = {"id": row["id"], "lesson": {"id": lesson["id"], "name": lesson["name"]}, "subject_id": lesson.get("subject_id", "math"), "total": len(row["questions"]),
108
+ "results": [right for _, right, _ in done]}
109
+ if attempts.is_open(row):
110
+ nxt = row["questions"][len(done)] if len(done) < len(row["questions"]) else None
111
+ served = bank.practice_question(nxt["skill_id"], nxt["key"]) if nxt else None
112
+ return {**base, "state": "open", "question": {**served, "skill_name": bank.skill(nxt["skill_id"])["name"]} if served else None}
113
+ uid = row["user_id"]
114
+ progress = {p["skill_id"]: p for p in store.progress_of(uid)}
115
+ started = _when(row["started_at"])
116
+ by_skill: dict[str, list[bool]] = {}
117
+ for q, right, _ in done:
118
+ by_skill.setdefault(q["skill_id"], []).append(right)
119
+ mastered, almost = [], []
120
+ for skill_id, results in by_skill.items():
121
+ p, skill = progress.get(skill_id), bank.skill(skill_id)
122
+ if skill is None:
123
+ continue
124
+ item = {"id": skill_id, "name": skill["name"]}
125
+ if p and p.get("mastered_at"):
126
+ if started and _when(p["mastered_at"]) >= started:
127
+ mastered.append(item)
128
+ elif len(results) >= 2 and all(results):
129
+ almost.append(item) # right in a row today: one more good answer on another day masters it
130
+ missed = []
131
+ for q, right, mistake in done:
132
+ if right:
133
+ continue
134
+ served = bank.practice_question(q["skill_id"], q["key"])
135
+ missed.append({"question": served["question"] if served else "", "skill_name": (bank.skill(q["skill_id"]) or {}).get("name", ""),
136
+ "mistake": {"name": mistake["name"], "tip": mistake["tip"]} if mistake else None})
137
+ all_rows = store.attempts_of(uid, KIND, row["scope"])
138
+ return {**base, "state": "done", "question": None,
139
+ "result": {"right": sum(base["results"]), "total": base["total"], "grade": row["grade"], "stars": _lesson_stars(request, uid, row["scope"], all_rows),
140
+ "mastered": mastered, "almost": almost, "missed": missed}}
141
+
142
+
143
+ def _mine(request: Request, user: dict, round_id: str) -> dict:
144
+ row = request.app.state.store.get_attempt(round_id)
145
+ if row is None or row["user_id"] != user["id"] or row["kind"] != KIND:
146
+ raise HTTPException(404, "הסבב לא נמצא")
147
+ return row
148
+
149
+
150
+ @router.post("/lessons/{lesson_id}/round/start")
151
+ def start(lesson_id: str, request: Request, user: dict = Depends(learner)) -> dict:
152
+ """Resume the lesson's open round, else draw a new one."""
153
+ _lesson(request, user, lesson_id)
154
+ store, bank, here, uid = request.app.state.store, request.app.state.bank, reads.of(request), user["id"]
155
+ here.prefetch(("attempts_of", uid, KIND, lesson_id), ("progress_of", uid))
156
+ mine = here.attempts_of(uid, KIND, lesson_id)
157
+ for row in mine:
158
+ if attempts.is_open(row):
159
+ return view(request, row)
160
+ states = {p["skill_id"]: skill_state(p) for p in here.progress_of(uid)}
161
+ used = Counter(q["key"] for row in mine for q in row["questions"])
162
+ drawn = draw(bank, lesson_id, states, used, getattr(request.app.state, "quiz_rng", None))
163
+ if not drawn:
164
+ raise HTTPException(409, "אין עדיין שאלות בשיעור הזה")
165
+ now = clock.now()
166
+ row = store.create_attempt(uid, KIND, lesson_id, now, now + timedelta(days=365), drawn) # untimed: the due date is never used
167
+ return view(request, row)
168
+
169
+
170
+ @router.get("/rounds/{round_id}")
171
+ def read(round_id: str, request: Request, user: dict = Depends(learner)) -> dict:
172
+ return view(request, _mine(request, user, round_id))
173
+
174
+
175
+ @router.post("/rounds/{round_id}/answer")
176
+ def answer(round_id: str, body: RoundAnswer, request: Request, user: dict = Depends(learner)) -> dict:
177
+ """Grade the next question of the round at once; store it as a practice answer; close the round after its last question."""
178
+ store, bank, uid = request.app.state.store, request.app.state.bank, user["id"]
179
+ row = _mine(request, user, round_id)
180
+ if not attempts.is_open(row):
181
+ raise HTTPException(409, "הסבב כבר הסתיים")
182
+ answered = len(_graded(bank, row))
183
+ expected = row["questions"][answered] if answered < len(row["questions"]) else None
184
+ if expected is None or expected["key"] != body.key:
185
+ raise HTTPException(409, "השאלה הזו כבר לא על המסך")
186
+ graded = bank.grade(expected["skill_id"], body.key, body.choice)
187
+ if graded is None:
188
+ raise HTTPException(422, "תשובה לא חוקית")
189
+ saved = store.save_open_answers(row["id"], {**row["answers"], body.key: body.choice})
190
+ if saved is None:
191
+ raise HTTPException(409, "הסבב כבר הסתיים")
192
+ skill_id, correct, mistake = expected["skill_id"], graded["correct"], graded["mistake"]
193
+ before = store.get_progress(uid, skill_id)
194
+ progress = record_practice_answer(store, uid, skill_id, body.key, correct, True, mistake["id"] if mistake else None,
195
+ clock.school_day(), clock.now().isoformat())
196
+ mastered_now = not (before or {}).get("mastered_at") and bool(progress.get("mastered_at"))
197
+ if not correct:
198
+ try:
199
+ store.add_review(uid, [(skill_id, body.key, "practice")], clock.now())
200
+ except Exception as exc: # bookkeeping never fails an answer
201
+ log.warning("could not add a wrong round answer to the review list: %s", type(exc).__name__)
202
+ if len(saved["answers"]) >= len(saved["questions"]):
203
+ right = sum(1 for _, r, _ in _graded(bank, saved) if r)
204
+ saved = store.save_attempt(saved["id"], submitted_at=clock.now(), timed_out=False,
205
+ grade=attempts.percent(right, len(saved["questions"])), answers=saved["answers"])
206
+ if mistake and mistake.get("back"):
207
+ back = bank.skill(mistake["back"])
208
+ mistake = {**mistake, "back_name": back["name"] if back else None}
209
+ return {"correct": correct, "mistake": mistake, "mastered_now": {"id": skill_id, "name": bank.skill(skill_id)["name"]} if mastered_now else None,
210
+ "round": view(request, saved)}
mathly2/skills.py CHANGED
@@ -8,7 +8,7 @@ import logging
8
  from fastapi import Query, APIRouter, Depends, HTTPException, Request
9
  from pydantic import BaseModel
10
 
11
- from . import attempts, clock, comets, quiz, reads
12
  from .access import learner
13
  from .learning import (DAILY_GOAL, MASTERED, current_round, is_faded, is_mastered, last_counted, next_in_round, next_skill, skill_state, streak,
14
  today_progress)
@@ -225,8 +225,9 @@ def lesson_rows(request: Request, user: dict, subject: str = "math") -> tuple[li
225
  """The student's lessons with their skills' states, whether each is completed (all skills mastered or its quiz passed) and behind the curriculum,
226
  and its quiz state. Shared by the path and the home. Returns (lessons, the skills in the bank's order, the skill states)."""
227
  store, here, uid = request.app.state.store, reads.of(request), user["id"]
228
- here.prefetch(("progress_of", uid), ("last_answered", uid), ("attempts_of", uid, "quiz"), ("curriculum",))
229
  states = {p["skill_id"]: skill_state(p) for p in here.progress_of(uid)}
 
230
  last, today = here.last_answered(uid), clock.school_day()
231
  quizzes_facts = quiz.facts(store, request.app.state.bank, uid, clock.now(), here)
232
  shown, dates = visible_lesson_ids(request, user), here.curriculum()
@@ -246,10 +247,29 @@ def lesson_rows(request: Request, user: dict, subject: str = "math") -> tuple[li
246
  lessons.append({"id": lesson["id"], "name": lesson["name"], "skills": skills, "mastered": mastered,
247
  "completed": completed, "passed": passed, "due_date": due.isoformat() if due else None,
248
  "behind": bool(due and due < today and not completed),
 
 
249
  "quiz": {"best": fact.get("best"), "open": bool(fact.get("open")), "due_at": fact["open"]["due_at"].isoformat() if fact.get("open") and fact["open"]["due_at"] else None}})
250
  return lessons, order, states
251
 
252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  def behind_lessons(request: Request, user: dict, subject: str = "math") -> list[dict]:
254
  """The lessons behind the curriculum for the home, the earliest date first: each with its quiz (the best grade so far and what passes it)."""
255
  lessons, _, _ = lesson_rows(request, user, subject)
@@ -275,7 +295,8 @@ def subject_param(request: Request, user: dict, subject: str) -> str:
275
  def path(request: Request, user: dict = Depends(learner), subject: str = Query("math", max_length=40)) -> dict:
276
  here, uid = reads.of(request), user["id"]
277
  subject = subject_param(request, user, subject)
278
- here.prefetch(("progress_of", uid), ("last_answered", uid), ("daily_correct", uid), ("recent_skills", uid, RECENT), ("attempts_of", uid, "quiz"), ("curriculum",))
 
279
  lessons, order, states = lesson_rows(request, user, subject)
280
  today = clock.school_day()
281
  daily = here.daily_correct(uid)
 
8
  from fastapi import Query, APIRouter, Depends, HTTPException, Request
9
  from pydantic import BaseModel
10
 
11
+ from . import attempts, clock, comets, quiz, reads, rounds
12
  from .access import learner
13
  from .learning import (DAILY_GOAL, MASTERED, current_round, is_faded, is_mastered, last_counted, next_in_round, next_skill, skill_state, streak,
14
  today_progress)
 
225
  """The student's lessons with their skills' states, whether each is completed (all skills mastered or its quiz passed) and behind the curriculum,
226
  and its quiz state. Shared by the path and the home. Returns (lessons, the skills in the bank's order, the skill states)."""
227
  store, here, uid = request.app.state.store, reads.of(request), user["id"]
228
+ here.prefetch(("progress_of", uid), ("last_answered", uid), ("attempts_of", uid, "quiz"), ("attempts_of", uid, rounds.KIND), ("curriculum",))
229
  states = {p["skill_id"]: skill_state(p) for p in here.progress_of(uid)}
230
+ round_facts = rounds.facts(here.attempts_of(uid, rounds.KIND))
231
  last, today = here.last_answered(uid), clock.school_day()
232
  quizzes_facts = quiz.facts(store, request.app.state.bank, uid, clock.now(), here)
233
  shown, dates = visible_lesson_ids(request, user), here.curriculum()
 
247
  lessons.append({"id": lesson["id"], "name": lesson["name"], "skills": skills, "mastered": mastered,
248
  "completed": completed, "passed": passed, "due_date": due.isoformat() if due else None,
249
  "behind": bool(due and due < today and not completed),
250
+ "stars": rounds.stars([sk["state"] for sk in skills], round_facts.get(lesson["id"], {}).get("best")),
251
+ "round_open": bool(round_facts.get(lesson["id"], {}).get("open")),
252
  "quiz": {"best": fact.get("best"), "open": bool(fact.get("open")), "due_at": fact["open"]["due_at"].isoformat() if fact.get("open") and fact["open"]["due_at"] else None}})
253
  return lessons, order, states
254
 
255
 
256
+ def lesson_map(request: Request, user: dict, subject: str = "math") -> dict:
257
+ """The home's map: every lesson with its stars, the next lesson to play, the stars collected and the daily goal with the streak.
258
+ The next lesson: the one with an open round, else the earliest behind the curriculum, else the first with fewer than three stars."""
259
+ here, uid, today = reads.of(request), user["id"], clock.school_day()
260
+ lessons, _, _ = lesson_rows(request, user, subject)
261
+ playable = [l for l in lessons if l["skills"]]
262
+ rows = [{"id": l["id"], "name": l["name"], "number": i + 1, "stars": l["stars"], "mastered": l["mastered"], "total": len(l["skills"]),
263
+ "completed": l["completed"], "behind": l["behind"], "due_date": l["due_date"], "round_open": l["round_open"]}
264
+ for i, l in enumerate(playable)]
265
+ behind = sorted((r for r in rows if r["behind"]), key=lambda r: (r["due_date"], r["number"]))
266
+ nxt = (next((r for r in rows if r["round_open"]), None) or (behind[0] if behind else None)
267
+ or next((r for r in rows if r["stars"] < 3), None))
268
+ daily = here.daily_correct(uid)
269
+ return {"lessons": rows, "next": nxt["id"] if nxt else None, "stars": sum(r["stars"] for r in rows), "max_stars": 3 * len(rows),
270
+ "perfect": sum(1 for r in rows if r["stars"] == 3), "today": today_progress(daily, today), "streak": streak(daily, today)}
271
+
272
+
273
  def behind_lessons(request: Request, user: dict, subject: str = "math") -> list[dict]:
274
  """The lessons behind the curriculum for the home, the earliest date first: each with its quiz (the best grade so far and what passes it)."""
275
  lessons, _, _ = lesson_rows(request, user, subject)
 
295
  def path(request: Request, user: dict = Depends(learner), subject: str = Query("math", max_length=40)) -> dict:
296
  here, uid = reads.of(request), user["id"]
297
  subject = subject_param(request, user, subject)
298
+ here.prefetch(("progress_of", uid), ("last_answered", uid), ("daily_correct", uid), ("recent_skills", uid, RECENT), ("attempts_of", uid, "quiz"),
299
+ ("attempts_of", uid, rounds.KIND), ("curriculum",))
300
  lessons, order, states = lesson_rows(request, user, subject)
301
  today = clock.school_day()
302
  daily = here.daily_correct(uid)
mathly2/static/app.css CHANGED
@@ -314,7 +314,7 @@ ul.grades .bar, ul.grades .muted { grid-column: 1 / -1; }
314
  /* The student's tab bar sits at the bottom of the screen, above the page and below the sheets */
315
  .tabs { position: fixed; inset-inline: 0; bottom: 0; z-index: 40; margin: 0; gap: 4px; padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px));
316
  background: var(--surface); border-top: 1px solid var(--line); }
317
- .tabs a { min-height: 44px; display: grid; place-items: center; border: 0; border-radius: 10px; font-weight: 600; font-size: 14px; padding-inline: 2px; min-width: 0; }
318
  body:has(.tabs) main { padding-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); }
319
 
320
  /* Home */
@@ -593,3 +593,59 @@ ul.students li [dir="ltr"] { overflow-wrap: anywhere; min-width: 0; }
593
  .le-mistake input { flex: 1 1 140px; width: auto; }
594
  .le-status { margin: 8px 0; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); }
595
  .st-facts dd.st-title { font-weight: 400; font-size: 14px; color: var(--muted); margin-top: 4px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  /* The student's tab bar sits at the bottom of the screen, above the page and below the sheets */
315
  .tabs { position: fixed; inset-inline: 0; bottom: 0; z-index: 40; margin: 0; gap: 4px; padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px));
316
  background: var(--surface); border-top: 1px solid var(--line); }
317
+ .tabs a { min-height: 44px; display: grid; place-items: center; border: 0; border-radius: 10px; font-weight: 600; font-size: 14px; padding-inline: 2px; min-width: 44px; flex: 1 1 auto; }
318
  body:has(.tabs) main { padding-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); }
319
 
320
  /* Home */
 
593
  .le-mistake input { flex: 1 1 140px; width: auto; }
594
  .le-status { margin: 8px 0; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--line); }
595
  .st-facts dd.st-title { font-weight: 400; font-size: 14px; color: var(--muted); margin-top: 4px; }
596
+
597
+ /* ---------- home: the lessons map (game-home-lesson-rounds) ---------- */
598
+ .quest { position: relative; margin-block: 12px; padding: 16px; border-radius: 20px; border: 1px solid color-mix(in srgb, var(--sky-gold) 45%, var(--line));
599
+ background: radial-gradient(120% 90% at 100% 0%, color-mix(in srgb, var(--sky-accent) 30%, transparent), transparent 60%),
600
+ radial-gradient(90% 80% at 0% 100%, color-mix(in srgb, var(--sky-aurora) 18%, transparent), transparent 70%), var(--surface); }
601
+ .quest-top { display: flex; align-items: center; gap: 12px; }
602
+ .quest h2 { margin: 0; font-size: 22px; }
603
+ .quest-stats { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 0; }
604
+ .quest-stats span { font-size: 14px; font-weight: 700; padding: 2px 10px; border-radius: 999px; background: color-mix(in srgb, var(--sky-night) 70%, transparent); border: 1px solid var(--line); }
605
+ .quest .goal { margin-block: 12px; }
606
+ .play { display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 12px 16px; border-radius: 18px; text-decoration: none; color: var(--sky-on-accent);
607
+ background: linear-gradient(135deg, var(--sky-gold), #ffb347); box-shadow: 0 6px 0 #c9852a, 0 10px 24px rgb(0 0 0 / .35); font-size: 18px; }
608
+ .play:active { transform: translateY(4px); box-shadow: 0 2px 0 #c9852a; }
609
+ .play:focus-visible { outline: 3px solid var(--sky-ink); outline-offset: 3px; }
610
+ .play small { display: block; font-size: 13px; font-weight: 600; opacity: .8; }
611
+ .play b { display: block; line-height: 1.25; }
612
+ .play .play-icon { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; background: var(--sky-on-accent); color: var(--sky-gold); font-size: 18px; flex: none; }
613
+ .play .lstars { color: var(--sky-on-accent); letter-spacing: 1px; }
614
+ .all-perfect { margin: 0; padding: 14px; border-radius: 16px; text-align: center; font-weight: 700; background: color-mix(in srgb, var(--sky-gold) 20%, var(--surface)); }
615
+
616
+ .lesson-map { position: relative; margin: 20px 0 8px; }
617
+ .map-trail { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
618
+ .map-trail path { fill: none; stroke: color-mix(in srgb, var(--sky-mute) 55%, var(--line)); stroke-width: 6; stroke-linecap: round; stroke-dasharray: 1 12; vector-effect: non-scaling-stroke; }
619
+ .map-trail path.on { stroke: var(--sky-accent); }
620
+ .map-trail path.gold { stroke: var(--sky-gold); stroke-dasharray: none; stroke-width: 5; }
621
+ .lnode { position: absolute; transform: translateX(-50%); width: 132px; display: flex; flex-direction: column; align-items: center; text-align: center;
622
+ text-decoration: none; color: var(--text); border-radius: 16px; }
623
+ .lnode:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
624
+ .lnode .disc { position: relative; display: grid; place-items: center; width: 68px; height: 68px; border-radius: 50%; font-size: 26px; font-weight: 800;
625
+ background: var(--sky-night); border: 3px solid var(--line); box-shadow: 0 5px 0 color-mix(in srgb, var(--sky-night) 60%, black); }
626
+ .lnode.s1 .disc, .lnode.s2 .disc { border-color: var(--sky-accent); background: color-mix(in srgb, var(--sky-accent) 22%, var(--sky-night)); }
627
+ .lnode.perfect .disc { border-color: var(--sky-gold); background: radial-gradient(circle at 35% 30%, #fff3c4, var(--sky-gold) 55%, #c9852a); box-shadow: 0 5px 0 #8a5a17, 0 0 22px color-mix(in srgb, var(--sky-gold) 55%, transparent); }
628
+ .lnode.behind .disc { border-color: var(--sky-danger); }
629
+ .lnode.next .disc { width: 80px; height: 80px; border-color: var(--sky-aurora); animation: sky-pulse 1.8s ease-out infinite; box-shadow: 0 0 0 6px color-mix(in srgb, var(--sky-aurora) 25%, transparent); }
630
+ .lnode .perch { position: absolute; top: -14px; inset-inline-end: -34px; width: 34px; pointer-events: none; }
631
+ .lnode .lstars { margin-top: 8px; font-size: 18px; line-height: 1; letter-spacing: 2px; color: var(--line); }
632
+ .lnode.s1 .lstars, .lnode.s2 .lstars, .lnode.s3 .lstars { color: var(--sky-gold); }
633
+ .lnode .lname { margin-top: 2px; font-size: 13px; line-height: 1.2; font-weight: 600; max-height: 2.4em; overflow: hidden; }
634
+ .lnode .lmark { margin-top: 3px; font-size: 11.5px; padding: 0 8px; border-radius: 999px; border: 1px solid var(--line); background: var(--sky-night); }
635
+ .lnode .lmark.late { color: var(--sky-danger); border-color: var(--sky-danger); }
636
+ .lnode .lmark.open { color: var(--sky-aurora); border-color: var(--sky-aurora); }
637
+ .map-key { font-size: 12.5px; text-align: center; margin: 0 0 12px; }
638
+
639
+ /* ---------- the lesson round's sheet ---------- */
640
+ .rd-skill { margin: 0 0 4px; font-size: 13px; }
641
+ .rd-result { text-align: center; }
642
+ .rd-bigstars { margin: 8px 0 0; font-size: 56px; line-height: 1; letter-spacing: 6px; color: var(--line); }
643
+ .rd-bigstars span { display: inline-block; }
644
+ .rd-bigstars span.on { color: var(--sky-gold); text-shadow: 0 0 18px color-mix(in srgb, var(--sky-gold) 60%, transparent);
645
+ animation: rd-pop .5s cubic-bezier(.2, 1.6, .4, 1) both; animation-delay: calc(var(--i) * .25s); }
646
+ @keyframes rd-pop { from { transform: scale(0) rotate(-30deg); opacity: 0; } to { transform: scale(1) rotate(0); opacity: 1; } }
647
+ .rd-title { margin: 8px 0 0; font-size: 22px; }
648
+ .rd-mastered { font-weight: 700; color: var(--sky-gold); }
649
+ .rd-almost { color: var(--sky-aurora); }
650
+ .rd-result .ex-missed { text-align: start; }
651
+ .lnode .lstars, .lnode .lname { position: relative; padding: 0 6px; border-radius: 8px; background: color-mix(in srgb, var(--sky-bg) 85%, transparent); }
mathly2/static/home.js CHANGED
@@ -1,9 +1,11 @@
1
- // The student's home for a subject: the nearest test with its readiness and one task for today, the required simulation,
2
- // the other tests, and a button that continues the path.
 
3
  import { swr } from "./api.js";
4
  import * as aiSheet from "./ai_sheet.js";
5
  import { esc, fmt } from "./math.js";
6
- import { backToSubjects, daysText, forSubject, pathHref, shortDate, tabs } from "./ui.js";
 
7
 
8
  /** One part of the readiness number as a row in plain Hebrew: its name, what the number is, and its value ("—" when there is none yet). */
9
  const part = (id, name, what, value, unit = "") => `<li class="rd-part" data-part="${id}"><span class="grow"><b>${name}</b> <span class="muted">${what}</span></span><b class="rd-val">${value === null ? "—" : value + unit}</b></li>`;
@@ -94,14 +96,6 @@ function assignmentsCard(all) {
94
  /** A grade as a chip inside the link it belongs to: the bare number, or "-" when there is none. */
95
  const chip = grade => `<span class="grade-chip${grade === null || grade === undefined ? " none" : ""}" data-best>${grade === null || grade === undefined ? "-" : grade}</span>`;
96
 
97
- /** Every lesson behind the curriculum, short: its name, its date and one link to its quiz with the best grade as a chip in it. */
98
- function behindCard(lessons) {
99
- if (!lessons?.length) return "";
100
- return `<section class="card" id="behind-card"><h2>שיעורים בפיגור</h2>
101
- <ul class="plain behind-list">${lessons.map(l => `<li class="behind-row" data-lesson="${esc(l.id)}"><span class="grow"><b>${fmt(l.name)}</b> <span class="late">⚠ עד ${shortDate(l.due_date)}</span></span>
102
- <a class="button with-chip" data-way="quiz" href="#/quiz/${encodeURIComponent(l.id)}">בוחן ${chip(l.quiz.best)}</a></li>`).join("")}</ul></section>`;
103
- }
104
-
105
  /** The nearest test's practice tests that were taken and are below 87 (13 of 15): to take again, inside the test's own card. */
106
  function weakTests(tests) {
107
  if (!tests?.length) return "";
@@ -120,18 +114,72 @@ function aiCard(ai) {
120
  <span class="tag st-${esc(r.state)}">${esc(aiSheet.stateWord(r.state))}</span> ${r.skill ? fmt(r.skill.name) : "תרגיל מצולם"}</button></li>`).join("")}</ul>` : ""}</section>`;
121
  }
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  export const show = view => swr([forSubject("/api/home"), "/api/assignments", "/api/ai/status"], ([data, assignments, ai]) => draw(view, data, assignments, ai));
124
 
 
 
125
  function draw(view, data, assignments, ai) {
126
  const [first] = data.tests;
127
  const required = data.tests.filter(t => t.simulation.required);
128
  view(`${tabs("home")}${backToSubjects()}
129
- ${first ? nearest(first, data.task, data.next_skill, data.weak_tests) : `<section class="card" id="no-test"><h2>הבית</h2>
130
- <p class="muted">המורה עוד לא קבע מבחן. בינתיים אפשר להמשיך במסלול.</p></section>`}
131
- ${behindCard(data.behind)}${assignmentsCard(assignments)}${aiCard(ai)}
132
  ${required.map(t => `<a class="required" href="#/sim/${t.id}" data-required="${t.id}"><b>חובה</b> מבחן סימולציה למבחן ב־${shortDate(t.date)}<span aria-hidden="true">‹</span></a>`).join("")}
133
- <p>${data.next_skill ? `<a class="primary-link" id="continue-path" href="#/skill/${encodeURIComponent(data.next_skill.id)}">להמשיך במסלול: ${fmt(data.next_skill.name)} <span aria-hidden="true">‹</span></a>`
134
- : `<a class="primary-link" id="continue-path" href="${pathHref()}">למסלול: כל הכוכבים פתוחים לתרגול <span aria-hidden="true">‹</span></a>`}</p>`);
 
 
 
135
  document.getElementById("ai-new")?.addEventListener("click", () => aiSheet.open());
136
  document.querySelectorAll("[data-ai-open]").forEach(b => b.addEventListener("click", () => aiSheet.open({ id: b.dataset.aiOpen })));
137
  }
 
1
+ // The student's home for a subject: a game board. The stars collected and a big play button for the next lesson, the map of the lessons
2
+ // (each with its stars: a round finished, 8 of 10, every skill mastered), then the nearest test with its readiness and today's task, the new
3
+ // assignments, the required simulation, and a link to the path for going deeper.
4
  import { swr } from "./api.js";
5
  import * as aiSheet from "./ai_sheet.js";
6
  import { esc, fmt } from "./math.js";
7
+ import { owl } from "./sky.js";
8
+ import { backToSubjects, daysText, forSubject, goalBar, pathHref, shortDate, tabs } from "./ui.js";
9
 
10
  /** One part of the readiness number as a row in plain Hebrew: its name, what the number is, and its value ("—" when there is none yet). */
11
  const part = (id, name, what, value, unit = "") => `<li class="rd-part" data-part="${id}"><span class="grow"><b>${name}</b> <span class="muted">${what}</span></span><b class="rd-val">${value === null ? "—" : value + unit}</b></li>`;
 
96
  /** A grade as a chip inside the link it belongs to: the bare number, or "-" when there is none. */
97
  const chip = grade => `<span class="grade-chip${grade === null || grade === undefined ? " none" : ""}" data-best>${grade === null || grade === undefined ? "-" : grade}</span>`;
98
 
 
 
 
 
 
 
 
 
99
  /** The nearest test's practice tests that were taken and are below 87 (13 of 15): to take again, inside the test's own card. */
100
  function weakTests(tests) {
101
  if (!tests?.length) return "";
 
114
  <span class="tag st-${esc(r.state)}">${esc(aiSheet.stateWord(r.state))}</span> ${r.skill ? fmt(r.skill.name) : "תרגיל מצולם"}</button></li>`).join("")}</ul>` : ""}</section>`;
115
  }
116
 
117
+ /* ---------- the lessons map: the home's game board ---------- */
118
+
119
+ const ROW = 128; // px between two lessons on the map
120
+ const XS = [50, 76, 50, 24]; // each lesson's place across the map, in %: a winding trail
121
+ const starsText = n => "★".repeat(n) + "☆".repeat(3 - n);
122
+ const starsLabel = n => n === 3 ? "שלושה כוכבים, מושלם" : n === 0 ? "אין עדיין כוכבים" : n === 1 ? "כוכב אחד" : "שני כוכבים";
123
+ export const roundHref = id => `#/round/${encodeURIComponent(id)}`;
124
+
125
+ /** The trail that joins the lessons: one curve per pair, gold after a perfect lesson, blue after a lesson with stars, faint otherwise. */
126
+ function trailSvg(lessons) {
127
+ const h = lessons.length * ROW;
128
+ const pt = i => [XS[i % XS.length], i * ROW + 40];
129
+ const parts = lessons.slice(0, -1).map((l, i) => {
130
+ const [x0, y0] = pt(i), [x1, y1] = pt(i + 1), m = (y0 + y1) / 2;
131
+ return `<path class="${l.stars === 3 ? "gold" : l.stars ? "on" : ""}" d="M${x0} ${y0}C${x0} ${m} ${x1} ${m} ${x1} ${y1}"/>`;
132
+ }).join("");
133
+ return `<svg class="map-trail" viewBox="0 0 100 ${h}" preserveAspectRatio="none" aria-hidden="true">${parts}</svg>`;
134
+ }
135
+
136
+ function lessonNode(l, i, next) {
137
+ const isNext = l.id === next, perfect = l.stars === 3;
138
+ const marks = [l.behind ? `<span class="lmark late">⏰ עד ${shortDate(l.due_date)}</span>` : "", l.round_open ? `<span class="lmark open">◐ באמצע סבב</span>` : ""].join("");
139
+ const label = `שיעור ${l.number}: ${plainText(l.name)}. ${starsLabel(l.stars)}${l.behind ? `. בפיגור, היה אמור להסתיים ב־${shortDate(l.due_date)}` : ""}${l.round_open ? ". באמצע סבב" : ""}${isNext ? ". ממשיכים כאן" : ""}`;
140
+ return `<a class="lnode s${l.stars}${perfect ? " perfect" : ""}${isNext ? " next" : ""}${l.behind ? " behind" : ""}" href="${roundHref(l.id)}" data-lesson="${esc(l.id)}" data-stars="${l.stars}"
141
+ style="left:${XS[i % XS.length]}%;top:${i * ROW}px" aria-label="${esc(label)}">
142
+ <span class="disc" aria-hidden="true">${perfect ? "👑" : l.number}${isNext ? `<span class="perch">${owl(34)}</span>` : ""}</span>
143
+ <span class="lstars" aria-hidden="true">${starsText(l.stars)}</span>
144
+ <span class="lname">${fmt(l.name)}</span>${marks}</a>`;
145
+ }
146
+
147
+ const plainText = text => String(text ?? "").replace(/\$/g, "");
148
+
149
+ /** The header and the play button: the stars collected, the lessons perfected, the daily goal and the streak. */
150
+ function quest(map) {
151
+ const next = map.lessons.find(l => l.id === map.next);
152
+ const play = next ? `<a class="play" id="play" href="${roundHref(next.id)}"><span class="play-icon" aria-hidden="true">▶</span>
153
+ <span class="grow"><small>${next.round_open ? "להמשיך את הסבב" : next.behind ? "להשלים" : next.stars ? "לעוד כוכב" : "השיעור הבא"}</small><b>${fmt(next.name)}</b></span>
154
+ <span class="lstars" aria-label="${starsLabel(next.stars)}">${starsText(next.stars)}</span></a>`
155
+ : `<p class="all-perfect" id="play">👑 כל השיעורים מושלמים! אפשר להעמיק במסלול.</p>`;
156
+ return `<section class="quest" id="quest" aria-label="המסע שלי">
157
+ <div class="quest-top">${owl(56)}<div class="grow"><h2>המסע שלי</h2>
158
+ <p class="quest-stats"><span id="stars-count">⭐ ${map.stars} מתוך ${map.max_stars}</span><span id="perfect-count">👑 ${map.perfect} מושלמים</span>${map.streak ? `<span>🔥 ${map.streak}</span>` : ""}</p></div></div>
159
+ ${goalBar(map.today)}${play}</section>`;
160
+ }
161
+
162
+ function lessonMap(map) {
163
+ if (!map.lessons.length) return "";
164
+ return `<section class="lesson-map" id="lesson-map" aria-label="מפת השיעורים" style="height:${map.lessons.length * ROW}px">${trailSvg(map.lessons)}
165
+ ${map.lessons.map((l, i) => lessonNode(l, i, map.next)).join("")}</section>
166
+ <p class="map-key muted">★ סבב הושלם · ★★ 8 מתוך 10 · ★★★ כל המיומנויות נשלטו (שני ימים טובים)</p>`;
167
+ }
168
+
169
  export const show = view => swr([forSubject("/api/home"), "/api/assignments", "/api/ai/status"], ([data, assignments, ai]) => draw(view, data, assignments, ai));
170
 
171
+ let scrolled = false;
172
+
173
  function draw(view, data, assignments, ai) {
174
  const [first] = data.tests;
175
  const required = data.tests.filter(t => t.simulation.required);
176
  view(`${tabs("home")}${backToSubjects()}
 
 
 
177
  ${required.map(t => `<a class="required" href="#/sim/${t.id}" data-required="${t.id}"><b>חובה</b> מבחן סימולציה למבחן ב־${shortDate(t.date)}<span aria-hidden="true">‹</span></a>`).join("")}
178
+ ${quest(data.map)}${assignmentsCard(assignments)}${lessonMap(data.map)}
179
+ ${first ? nearest(first, data.task, data.next_skill, data.weak_tests) : ""}
180
+ ${aiCard(ai)}
181
+ <p><a class="primary-link" id="continue-path" href="${pathHref()}">להעמקה: כל המיומנויות, אחת־אחת <span aria-hidden="true">‹</span></a></p>`);
182
+ if (!scrolled) { scrolled = true; document.querySelector(".lnode.next")?.scrollIntoView?.({ block: "nearest" }); }
183
  document.getElementById("ai-new")?.addEventListener("click", () => aiSheet.open());
184
  document.querySelectorAll("[data-ai-open]").forEach(b => b.addEventListener("click", () => aiSheet.open({ id: b.dataset.aiOpen })));
185
  }
mathly2/static/path.js CHANGED
@@ -51,8 +51,8 @@ function lesson(l, next) {
51
 
52
  export function pathHtml(path) {
53
  return `${tabs("path")}${backToSubjects()}
54
- <section class="card path-top" id="path"><h2>המסלול שלי</h2>
55
- <p class="muted">כל השיעורים פתוחים, בכל סדר. הינשוף מציע איפה להמשיך, אבל אפשר ללחוץ על כל כוכב.</p>${goalBar(path.today, path.streak)}${behindNote(path.behind)}</section>
56
  <div class="trail">
57
  <div class="mark start">${owl(44)}<b>התחלה</b></div>
58
  ${path.lessons.map(l => lesson(l, path.next)).join("")}
 
51
 
52
  export function pathHtml(path) {
53
  return `${tabs("path")}${backToSubjects()}
54
+ <section class="card path-top" id="path"><h2>העמקה: המסלול המלא</h2>
55
+ <p class="muted">כאן מתרגלים כל מיומנות לבד, לעומק. את השיעורים משחקים במפה שבבית.</p>${goalBar(path.today, path.streak)}${behindNote(path.behind)}</section>
56
  <div class="trail">
57
  <div class="mark start">${owl(44)}<b>התחלה</b></div>
58
  ${path.lessons.map(l => lesson(l, path.next)).join("")}
mathly2/static/round.js ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // A lesson round (סבב שיעור), opened from the home's map: a short round of the lesson's skills, one question at a time, with instant feedback
2
+ // like the exercise screen. A wrong answer names the mistake and never marks the right choice. The last answer shows the lesson's stars.
3
+ import { api, ApiError } from "./api.js";
4
+ import { figures } from "./figures.js";
5
+ import { esc, fmt } from "./math.js";
6
+ import { loadBoard } from "./exercise.js";
7
+ import { homeHref, shuffled } from "./ui.js";
8
+
9
+ const COUNTDOWN = 2; // seconds before the next question after a right answer
10
+ let layer = null, round = null, timer = null, streak = 0;
11
+
12
+ const q = selector => layer?.querySelector(selector);
13
+ const stop = () => { clearTimeout(timer); timer = null; };
14
+ const starsText = n => "★".repeat(n) + "☆".repeat(3 - n);
15
+ const CHEERS = ["נכון!", "יופי!", "מצוין!", "בדיוק!", "אלופים!"];
16
+
17
+ export const isOpen = () => !!layer;
18
+
19
+ /** Start the lesson's round (or resume the open one). Returns the round, so the router knows its subject. */
20
+ export const start = lesson => api(`/api/lessons/${encodeURIComponent(lesson)}/round/start`, { method: "POST" });
21
+
22
+ /** Show a round from `start` as a sheet over the home. */
23
+ export async function show(data) {
24
+ stop();
25
+ await loadBoard();
26
+ round = data;
27
+ streak = 0;
28
+ if (!layer) {
29
+ layer = document.createElement("div");
30
+ layer.className = "mx-layer round-layer";
31
+ document.body.append(layer);
32
+ layer.addEventListener("click", onClick);
33
+ }
34
+ layer.innerHTML = `<div class="mx-scrim" data-close></div><div class="mx-sheet" role="dialog" aria-modal="true" aria-labelledby="rd-title"></div>`;
35
+ render();
36
+ q(".mx-x")?.focus({ preventScroll: true });
37
+ }
38
+
39
+ export function close() {
40
+ if (!layer) return;
41
+ stop();
42
+ window.MathlyBoard?.closeFull?.();
43
+ layer.remove();
44
+ layer = round = null;
45
+ }
46
+
47
+ const heading = () => `<div class="mx-shtop"><div><span class="ex-chip">סבב שיעור</span><h3 id="rd-title">${fmt(round.lesson.name)}</h3></div>
48
+ <div class="mx-shacts">${window.MathlyBoard?.button() || ""}<button type="button" class="mx-x" data-close aria-label="סגירה">✕</button></div></div>`;
49
+
50
+ const dots = () => Array.from({ length: round.total }, (_, i) => {
51
+ const r = round.results[i];
52
+ return `<i class="${r === true ? "ok" : r === false ? "bad" : ""}">${r === false ? "✗" : ""}</i>`;
53
+ }).join("");
54
+ const countText = () => round.state === "done" ? "הסבב הסתיים" : `שאלה ${Math.min(round.results.length + 1, round.total)} מתוך ${round.total}`;
55
+ const progress = () => `<div class="ex-progress"><span id="rd-count">${countText()}</span>
56
+ <span class="ex-dots" id="rd-dots" role="img" aria-label="נענו ${round.results.length} מתוך ${round.total}, ${round.results.filter(Boolean).length} נכונות">${dots()}</span></div>`;
57
+
58
+ function questionHtml() {
59
+ const question = round.question;
60
+ return `<div class="mx-q" id="ex-q" data-key="${esc(question.key)}"><p class="muted rd-skill">${fmt(question.skill_name)}</p>
61
+ <p class="ex-text" dir="auto">${fmt(question.question)}</p>${figures(question)}
62
+ <div class="ex-choices" id="ex-choices">${shuffled(question.choices).map(c =>
63
+ `<button type="button" class="ex-choice" data-i="${c.index}" dir="auto">${fmt(c.text)}</button>`).join("")}</div>
64
+ <div class="ex-fb" id="ex-fb" role="status" aria-live="polite"></div></div>`;
65
+ }
66
+
67
+ /** The round's result: the stars big, the score, what was mastered, what is one good day away, and the mistakes (never the right answers). */
68
+ function resultHtml() {
69
+ const r = round.result;
70
+ const names = list => list.map(s => fmt(s.name)).join(", ");
71
+ const title = r.stars === 3 ? "👑 השיעור מושלם!" : r.right === r.total ? "מושלם! כל התשובות נכונות" : r.right * 10 >= r.total * 8 ? "כל הכבוד!" : "סיימתם סבב!";
72
+ return `<section class="rd-result" id="rd-result">
73
+ <p class="rd-bigstars" aria-label="${r.stars} מתוך 3 כוכבים">${[0, 1, 2].map(i => `<span class="${i < r.stars ? "on" : ""}" style="--i:${i}">${i < r.stars ? "★" : "☆"}</span>`).join("")}</p>
74
+ <h4 class="rd-title">${title}</h4>
75
+ <p class="ex-score" id="rd-score"><b>${r.right}</b> מתוך ${r.total}</p>
76
+ ${r.mastered.length ? `<p class="rd-mastered" id="rd-mastered">🎉 נשלטו: ${names(r.mastered)}</p>` : ""}
77
+ ${r.almost.length ? `<p class="rd-almost" id="rd-almost">🔜 עוד סבב טוב ביום אחר ונשלטות: ${names(r.almost)}</p>` : ""}
78
+ ${r.stars < 3 ? `<p class="muted rd-next">${r.stars < 2 ? "8 מתוך 10 נותנים כוכב שני." : "הכוכב השלישי: כל המיומנויות נשלטו. חוזרים מחר לעוד סבב."}</p>` : ""}
79
+ ${r.missed.length ? `<h4>מה כדאי לחזק</h4><ol class="ex-missed" id="rd-missed">${r.missed.map(m => `<li><p class="muted">${fmt(m.skill_name)}</p><p class="ex-text" dir="auto">${fmt(m.question)}</p>
80
+ ${m.mistake ? `<p><b>${esc(m.mistake.name)}</b>${m.mistake.tip ? `<br>${fmt(m.mistake.tip)}` : ""}</p>` : ""}</li>`).join("")}</ol>` : ""}
81
+ <p class="ex-actions"><button type="button" class="primary" id="rd-again">סבב נוסף</button> <button type="button" data-close>חזרה למפה</button></p></section>`;
82
+ }
83
+
84
+ function mountBoard() {
85
+ const key = round.question ? `round:${round.id}:${round.question.key}` : `round:${round.id}:done`;
86
+ window.MathlyBoard?.mount(layer, key, { question: () => q("#ex-q") });
87
+ }
88
+
89
+ function render(notice = "") {
90
+ q(".mx-sheet").innerHTML = `${heading()}${notice ? `<p class="ex-note" role="status">${esc(notice)}</p>` : ""}${progress()}
91
+ ${round.state === "done" ? resultHtml() : questionHtml()}`;
92
+ mountBoard();
93
+ }
94
+
95
+ function updateProgress() {
96
+ q("#rd-count").textContent = countText();
97
+ q("#rd-dots").innerHTML = dots();
98
+ }
99
+
100
+ function countdown(next) {
101
+ let left = COUNTDOWN;
102
+ const label = q("#ex-countdown");
103
+ const tick = () => {
104
+ if (!layer || !label?.isConnected) return;
105
+ if (left === 0) return next();
106
+ label.textContent = `הבאה בעוד ${left}…`;
107
+ left -= 1;
108
+ timer = setTimeout(tick, 1000);
109
+ };
110
+ tick();
111
+ }
112
+
113
+ async function answer(button) {
114
+ const buttons = [...layer.querySelectorAll(".ex-choice")];
115
+ buttons.forEach(b => { b.disabled = true; b.classList.toggle("dim", b !== button); });
116
+ button.classList.add("pending");
117
+ button.setAttribute("aria-busy", "true");
118
+ q("#ex-fb").innerHTML = `<p class="ex-thinking">בודקים…</p>`;
119
+ let result;
120
+ try {
121
+ result = await api(`/api/rounds/${encodeURIComponent(round.id)}/answer`, { method: "POST", body: { key: round.question.key, choice: Number(button.dataset.i) } });
122
+ } catch (error) {
123
+ buttons.forEach(b => b.classList.remove("pending", "dim"));
124
+ button.removeAttribute("aria-busy");
125
+ if (error instanceof ApiError && error.status === 409) { // answered in another tab: show where the round is now
126
+ round = await api(`/api/rounds/${encodeURIComponent(round.id)}`);
127
+ return render("הסבב התקדם בחלון אחר. זו השאלה שעל המסך עכשיו.");
128
+ }
129
+ buttons.forEach(b => { b.disabled = false; });
130
+ q("#ex-fb").innerHTML = `<p class="error" role="alert">${esc(error.message || "משהו השתבש")}</p>`;
131
+ return;
132
+ }
133
+ buttons.forEach(b => b.classList.remove("pending", "dim"));
134
+ button.removeAttribute("aria-busy");
135
+ const following = result.round;
136
+ round = { ...round, results: following.results, state: following.state };
137
+ updateProgress();
138
+ const next = () => { stop(); round = following; render(); };
139
+ const last = following.state === "done";
140
+ const feedback = q("#ex-fb");
141
+ const mastered = result.mastered_now ? `<p class="rd-mastered">🎉 ${fmt(result.mastered_now.name)}: נשלטה!</p>` : "";
142
+ if (result.correct) {
143
+ streak += 1;
144
+ button.classList.add("right");
145
+ button.insertAdjacentHTML("beforeend", '<span class="burst" aria-hidden="true">✦ ✦ ✦</span>');
146
+ navigator.vibrate?.(25);
147
+ feedback.innerHTML = `<div class="ex-ok"><b>${CHEERS[(streak - 1) % CHEERS.length]} ✓</b>${streak >= 3 ? ` <span>🔥 ${streak} ברצף</span>` : ""}${mastered}
148
+ <div class="ex-actions">${last ? `<button type="button" class="primary" id="rd-next">לתוצאות ✨</button>`
149
+ : `<span id="ex-countdown"></span><button type="button" id="rd-next">לשאלה הבאה</button>`}</div></div>`;
150
+ if (!last) countdown(next);
151
+ } else {
152
+ streak = 0;
153
+ button.classList.add("wrong");
154
+ button.insertAdjacentHTML("beforeend", '<span class="verdict"><span aria-hidden="true">✗</span> לא נכון</span>');
155
+ const m = result.mistake;
156
+ feedback.innerHTML = `<div class="ex-bad"><b>${esc(m?.name || "תשובה לא נכונה")}</b>${m?.tip ? `<p>${fmt(m.tip)}</p>` : ""}
157
+ <div class="ex-actions"><button type="button" id="rd-next">${last ? "לתוצאות" : "לשאלה הבאה"}</button></div></div>`;
158
+ }
159
+ q("#rd-next").addEventListener("click", next);
160
+ }
161
+
162
+ async function again() {
163
+ const button = q("#rd-again");
164
+ button.disabled = true;
165
+ try {
166
+ round = await start(round.lesson.id);
167
+ streak = 0;
168
+ render();
169
+ } catch (error) {
170
+ button.disabled = false;
171
+ button.insertAdjacentHTML("afterend", `<p class="error" role="alert">${esc(error.message || "משהו השתבש")}</p>`);
172
+ }
173
+ }
174
+
175
+ function onClick(event) {
176
+ const target = event.target;
177
+ if (target.closest("[data-close]")) { location.hash = homeHref(); return; }
178
+ if (target.closest("[data-board-toggle]")) { window.MathlyBoard?.toggle(); return; }
179
+ if (target.closest("#rd-again")) { again(); return; }
180
+ const choice = target.closest(".ex-choice");
181
+ if (choice && !choice.disabled) answer(choice);
182
+ }
183
+
184
+ window.addEventListener("mathly:leave", close);
185
+
186
+ document.addEventListener("keydown", event => {
187
+ if (event.key === "Escape" && layer && !document.querySelector(".mx-board.full")) location.hash = homeHref();
188
+ });
mathly2/static/student.js CHANGED
@@ -9,6 +9,7 @@ import * as home from "./home.js";
9
  import * as phone from "./phone.js";
10
  import * as path from "./path.js";
11
  import * as quiz from "./quiz.js";
 
12
  import { route as onRoute } from "./router.js";
13
 
14
  export async function show(me, view) {
@@ -23,8 +24,15 @@ export async function show(me, view) {
23
  phone.stopComputer(); // a page that was waiting for the phone's photo is left
24
  const raw = location.hash.replace(/^#/, "");
25
  const where = raw.startsWith("/") ? raw : "/"; // no address, or the sign-in service's "#access_token=…": the first screen
 
26
  try {
27
- if (where.startsWith("/skill/")) {
 
 
 
 
 
 
28
  quiz.close();
29
  lastSkill = decodeURIComponent(where.slice(7));
30
  if (!document.getElementById("path")) { // a link or a reload: the skill's subject's path first, then the sheet
 
9
  import * as phone from "./phone.js";
10
  import * as path from "./path.js";
11
  import * as quiz from "./quiz.js";
12
+ import * as lessonRound from "./round.js";
13
  import { route as onRoute } from "./router.js";
14
 
15
  export async function show(me, view) {
 
24
  phone.stopComputer(); // a page that was waiting for the phone's photo is left
25
  const raw = location.hash.replace(/^#/, "");
26
  const where = raw.startsWith("/") ? raw : "/"; // no address, or the sign-in service's "#access_token=…": the first screen
27
+ if (!where.startsWith("/round/")) lessonRound.close();
28
  try {
29
+ if (where.startsWith("/round/")) { // a lesson's round over the home (a link or a reload: the home first)
30
+ exercise.close(); quiz.close();
31
+ const data = await lessonRound.start(decodeURIComponent(where.slice(7)));
32
+ setSubject(data.subject_id);
33
+ if (!document.getElementById("lesson-map")) await home.show(view);
34
+ await lessonRound.show(data);
35
+ } else if (where.startsWith("/skill/")) {
36
  quiz.close();
37
  lastSkill = decodeURIComponent(where.slice(7));
38
  if (!document.getElementById("path")) { // a link or a reload: the skill's subject's path first, then the sheet
mathly2/static/ui.js CHANGED
@@ -19,10 +19,11 @@ export const pathHref = () => `#/subject/${encodeURIComponent(subject)}/path`;
19
  /** A student API address for the current subject ("/api/path" -> "/api/path?subject=math"). */
20
  export const forSubject = url => `${url}${url.includes("?") ? "&" : "?"}subject=${encodeURIComponent(subject)}`;
21
 
22
- /** The student's tab bar, fixed at the bottom of the screen (the CSS places it): home, path, grades, collection. */
 
23
  export const tabs = active => `<nav class="tabs" aria-label="תפריט התלמיד">
24
  <a href="${homeHref()}" ${active === "home" ? 'aria-current="page"' : ""}>בית</a>
25
- <a href="${pathHref()}" ${active === "path" ? 'aria-current="page"' : ""}>מסלול</a>
26
  <a href="#/submissions" ${active === "submissions" ? 'aria-current="page"' : ""}>הגשות</a>
27
  <a href="#/grades" ${active === "grades" ? 'aria-current="page"' : ""}>ציונים</a>
28
  <a href="#/collection" ${active === "collection" ? 'aria-current="page"' : ""}>האוסף</a>
 
19
  /** A student API address for the current subject ("/api/path" -> "/api/path?subject=math"). */
20
  export const forSubject = url => `${url}${url.includes("?") ? "&" : "?"}subject=${encodeURIComponent(subject)}`;
21
 
22
+ /** The student's tab bar, fixed at the bottom of the screen (the CSS places it): home (the lessons map), the path ("העמקה": going deeper,
23
+ * skill by skill), submissions, grades, collection, review. */
24
  export const tabs = active => `<nav class="tabs" aria-label="תפריט התלמיד">
25
  <a href="${homeHref()}" ${active === "home" ? 'aria-current="page"' : ""}>בית</a>
26
+ <a href="${pathHref()}" ${active === "path" ? 'aria-current="page"' : ""}>העמקה</a>
27
  <a href="#/submissions" ${active === "submissions" ? 'aria-current="page"' : ""}>הגשות</a>
28
  <a href="#/grades" ${active === "grades" ? 'aria-current="page"' : ""}>ציונים</a>
29
  <a href="#/collection" ${active === "collection" ? 'aria-current="page"' : ""}>האוסף</a>
mathly2/supabase/migrations/20261118090000_m2_lesson_rounds.sql ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ -- Mathly 2: lesson rounds (סבב שיעור), a short round of a lesson's skills with instant feedback, kept as m2_attempts rows of kind
2
+ -- lesson_round (scope: the lesson id). Its answers are also stored as practice answers in m2_answers. One widened check.
3
+ -- Rollback: delete from public.m2_attempts where kind = 'lesson_round';
4
+ -- alter table public.m2_attempts drop constraint m2_attempts_kind_check;
5
+ -- alter table public.m2_attempts add constraint m2_attempts_kind_check check (kind in ('quiz', 'exercise_test', 'simulation'));
6
+ begin;
7
+ alter table public.m2_attempts drop constraint m2_attempts_kind_check;
8
+ alter table public.m2_attempts add constraint m2_attempts_kind_check check (kind in ('quiz', 'exercise_test', 'simulation', 'lesson_round'));
9
+ commit;
mathly2/tests/test_behind_home_browser.py CHANGED
@@ -17,42 +17,6 @@ def seed(app, store, bank, lessons):
17
  store.set_lesson_dates({lessons[0]: today - timedelta(days=2), lessons[1]: today - timedelta(days=9)})
18
 
19
 
20
- def test_the_card_lists_the_lessons_behind_in_date_order_each_with_only_the_quiz_link(browser, bank):
21
- app, store, klass, lessons = world(bank)
22
- seed(app, store, bank, lessons)
23
- q = store.create_attempt(KID_ID, "quiz", lessons[0], clock.now() - timedelta(hours=2), clock.now() - timedelta(hours=1), [])
24
- store.save_attempt(q["id"], submitted_at=clock.now() - timedelta(hours=1), grade=80)
25
- with running(app) as url:
26
- page = sign_in(browser, url)
27
- to_home(page)
28
- page.wait_for_selector("#behind-card")
29
- rows = page.locator("#behind-card li")
30
- assert rows.count() == 2 and rows.nth(0).get_attribute("data-lesson") == lessons[1] and rows.nth(1).get_attribute("data-lesson") == lessons[0] # the earlier date first
31
- assert page.locator("#behind-card [data-way=skills]").count() == 0 and page.locator("#behind-card a").count() == 2 # only the quiz links
32
- mine = page.locator(f"#behind-card li[data-lesson='{lessons[0]}']")
33
- link = mine.locator("[data-way=quiz]")
34
- assert link.get_attribute("href") == f"#/quiz/{lessons[0]}" and link.inner_text().split() == ["בוחן", "80"]
35
- assert link.locator(".grade-chip[data-best]").inner_text() == "80" and rows.nth(0).locator("[data-way=quiz] .grade-chip").inner_text() == "-" # a chip inside the link
36
- chip, button = link.locator(".grade-chip").bounding_box(), link.bounding_box()
37
- assert button["x"] <= chip["x"] and chip["x"] + chip["width"] <= button["x"] + button["width"] and chip["y"] >= button["y"] # attached, not floating beside it
38
- assert "13" not in page.inner_text("#behind-card") and "מתוך" not in page.inner_text("#behind-card") # nothing explains the pass line
39
- assert "⚠" in mine.inner_text() and "מיומנויות" not in page.inner_text("#behind-card")
40
- no_sideways_scroll(page)
41
- assert not page.errors
42
-
43
-
44
- def test_the_quiz_link_opens_the_quiz(browser, bank):
45
- app, store, klass, lessons = world(bank)
46
- seed(app, store, bank, lessons)
47
- with running(app) as url:
48
- page = sign_in(browser, url)
49
- to_home(page)
50
- page.wait_for_selector("#behind-card")
51
- page.click(f"#behind-card li[data-lesson='{lessons[1]}'] [data-way=quiz]")
52
- page.wait_for_selector("#qz-q")
53
- assert page.evaluate("location.hash").startswith(f"#/quiz/{lessons[1]}/")
54
-
55
-
56
  def test_practice_tests_below_87_are_inside_the_nearest_test_card_with_the_grade_in_the_button(browser, bank):
57
  app, store, klass, lessons = world(bank)
58
  test = store.create_test(klass["id"], clock.school_day() + timedelta(days=10), lessons[:1], "t@school.test", created_at=clock.now() - timedelta(days=20))
 
17
  store.set_lesson_dates({lessons[0]: today - timedelta(days=2), lessons[1]: today - timedelta(days=9)})
18
 
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  def test_practice_tests_below_87_are_inside_the_nearest_test_card_with_the_grade_in_the_button(browser, bank):
21
  app, store, klass, lessons = world(bank)
22
  test = store.create_test(klass["id"], clock.school_day() + timedelta(days=10), lessons[:1], "t@school.test", created_at=clock.now() - timedelta(days=20))
mathly2/tests/test_home_browser.py CHANGED
@@ -81,8 +81,8 @@ def test_the_card_opens_home_and_each_tab_opens_its_screen(browser, bank):
81
  page = sign_in(browser, url)
82
  to_home(page)
83
  assert page.evaluate("location.hash") == "#/subject/math" and page.locator(".tabs a[aria-current='page']").inner_text() == "בית"
84
- assert page.locator(".tabs a").all_inner_texts() == ["בית", "מסלול", "הגשות", "ציונים", "האוסף", "חזרה"]
85
- for href, marker in (("#/subject/math/path", ".orbit"), ("#/submissions", "#submissions"), ("#/grades", "#grades"), ("#/collection", "#collection"), ("#/review", "#review"), ("#/subject/math", "#no-test")):
86
  page.click(f".tabs a[href='{href}']")
87
  page.wait_for_selector(marker)
88
  assert page.errors == []
@@ -119,14 +119,15 @@ def test_a_link_to_a_skill_still_opens_the_path_with_its_sheet(browser, bank):
119
 
120
  # --- 3.2 home ---
121
 
122
- def test_with_no_test_home_says_so_and_continues_the_path(browser, bank):
123
  app, _, _, lessons = world(bank)
124
- first = covered(bank, lessons)[0]
125
  with running(app) as url:
126
  page = sign_in(browser, url)
127
  to_home(page)
128
- assert "עוד לא קבע מבחן" in page.inner_text("#no-test") and page.locator("#nearest-test").count() == 0
129
- assert page.get_attribute("#continue-path", "href") == f"#/skill/{first}"
 
 
130
  no_sideways_scroll(page)
131
 
132
 
 
81
  page = sign_in(browser, url)
82
  to_home(page)
83
  assert page.evaluate("location.hash") == "#/subject/math" and page.locator(".tabs a[aria-current='page']").inner_text() == "בית"
84
+ assert page.locator(".tabs a").all_inner_texts() == ["בית", "העמקה", "הגשות", "ציונים", "האוסף", "חזרה"]
85
+ for href, marker in (("#/subject/math/path", ".orbit"), ("#/submissions", "#submissions"), ("#/grades", "#grades"), ("#/collection", "#collection"), ("#/review", "#review"), ("#/subject/math", "#lesson-map")):
86
  page.click(f".tabs a[href='{href}']")
87
  page.wait_for_selector(marker)
88
  assert page.errors == []
 
119
 
120
  # --- 3.2 home ---
121
 
122
+ def test_with_no_test_home_shows_the_map_and_a_link_to_the_path(browser, bank):
123
  app, _, _, lessons = world(bank)
 
124
  with running(app) as url:
125
  page = sign_in(browser, url)
126
  to_home(page)
127
+ page.wait_for_selector("#lesson-map")
128
+ assert page.locator("#nearest-test").count() == 0
129
+ assert page.get_attribute("#continue-path", "href") == "#/subject/math/path"
130
+ assert page.get_attribute("#play", "href") == f"#/round/{lessons[0]}"
131
  no_sideways_scroll(page)
132
 
133
 
mathly2/tests/test_lesson_map_browser.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The home's lessons map and a lesson round in a real browser (game-home-lesson-rounds 2.1-2.3)."""
2
+ import os
3
+ from datetime import timedelta
4
+
5
+ import pytest
6
+
7
+ playwright = pytest.importorskip("playwright.sync_api")
8
+
9
+ from mathly2 import clock
10
+ from mathly2.bank import load_bank
11
+ from mathly2.config import Settings
12
+ from mathly2.main import create_app
13
+ from mathly2.store import InMemoryStore
14
+ from mathly2.tests.serve import offline_formulas, only, running
15
+ from mathly2.tests.test_home_browser import KID, KID_ID, PHONE, TEACHER, no_sideways_scroll, sign_in, to_home
16
+
17
+ SHOTS = os.environ.get("MATHLY_SHOTS") # a folder: save screenshots there to look at the screens
18
+ STAMP = "2026-10-05T10:00:00+00:00"
19
+
20
+
21
+ @pytest.fixture(scope="module")
22
+ def bank():
23
+ return load_bank()
24
+
25
+
26
+ @pytest.fixture
27
+ def browser():
28
+ with playwright.sync_playwright() as p:
29
+ b = p.chromium.launch()
30
+ yield b
31
+ b.close()
32
+
33
+
34
+ def world(bank, n=4):
35
+ lessons = [l["id"] for l in bank.lessons if any(s["unit"] == l["id"] and bank.question_keys(s["id"]) for s in bank.skills)][:n]
36
+ store = InMemoryStore()
37
+ klass = store.create_class("setup", "A", lessons=lessons, teacher_email=TEACHER)
38
+ store.add_member(klass["id"], KID)
39
+ small = only(bank, lessons)
40
+ return create_app(Settings(), bank=small, store=store), store, small, lessons
41
+
42
+
43
+ def shot(page, name):
44
+ if SHOTS:
45
+ page.screenshot(path=os.path.join(SHOTS, f"{name}.png"), full_page=True)
46
+
47
+
48
+ def right_index(bank, key):
49
+ skill = next(s for s in bank.skills if key in bank.question_keys(s["id"]))
50
+ from mathly2.bank import question_key
51
+ question = next(q for q in skill["questions"] if question_key(skill["id"], q) == key)
52
+ return next(i for i, c in enumerate(question["choices"]) if c.get("correct"))
53
+
54
+
55
+ def test_home_is_a_map_of_the_lessons_with_stars_and_a_play_button(browser, bank):
56
+ app, store, small, lessons = world(bank)
57
+ first = [s["id"] for s in small.skills if s["unit"] == lessons[0]]
58
+ for s in first:
59
+ store.save_progress(KID_ID, s, started_at=STAMP, mastered_at=STAMP) # lesson 1 perfect
60
+ store.set_lesson_dates({lessons[2]: clock.school_day() - timedelta(days=3)}) # lesson 3 behind
61
+ with running(app) as url:
62
+ page = sign_in(browser, url)
63
+ to_home(page)
64
+ page.wait_for_selector("#lesson-map")
65
+ nodes = page.locator(".lnode")
66
+ assert nodes.count() == len(lessons)
67
+ assert nodes.nth(0).get_attribute("data-stars") == "3" and "👑" in nodes.nth(0).inner_text()
68
+ assert "⏰" in nodes.nth(2).inner_text()
69
+ assert page.locator("#stars-count").inner_text() == f"⭐ 3 מתוך {3 * len(lessons)}"
70
+ assert page.locator("#play").get_attribute("href") == f"#/round/{lessons[2]}" # the behind lesson comes first
71
+ assert page.locator(".lnode.next").get_attribute("data-lesson") == lessons[2]
72
+ assert page.locator(".tabs a").nth(1).inner_text() == "העמקה" and page.locator("#behind-card").count() == 0
73
+ no_sideways_scroll(page)
74
+ shot(page, "home")
75
+ assert page.errors == []
76
+
77
+
78
+ def test_a_round_from_the_map_gives_feedback_and_ends_with_stars(browser, bank):
79
+ app, store, small, lessons = world(bank, 2)
80
+ with running(app) as url:
81
+ page = sign_in(browser, url)
82
+ to_home(page)
83
+ page.click("#play")
84
+ page.wait_for_selector(".round-layer #ex-q")
85
+ total = int(page.locator("#rd-count").inner_text().split()[-1])
86
+ shot(page, "round-question")
87
+ for i in range(total):
88
+ key = page.locator("#ex-q").get_attribute("data-key")
89
+ pick = right_index(small, key) if i < total - 1 else None
90
+ if pick is None: # the last one wrong
91
+ page.locator(f".ex-choice:not([data-i='{right_index(small, key)}'])").first.click()
92
+ page.wait_for_selector(".ex-bad")
93
+ shot(page, "round-wrong")
94
+ else:
95
+ page.click(f".ex-choice[data-i='{pick}']")
96
+ page.wait_for_selector(".ex-ok")
97
+ page.click("#rd-next")
98
+ if i < total - 1:
99
+ page.wait_for_function(f"document.querySelector('#rd-count').textContent.includes('{i + 2} מתוך')")
100
+ page.wait_for_selector("#rd-result")
101
+ assert page.locator("#rd-score").inner_text().replace("\n", " ").startswith(f"{total - 1}")
102
+ assert page.locator(".rd-bigstars span.on").count() == 2 # 9 of 10: two stars
103
+ shot(page, "round-result")
104
+ page.click("#rd-result [data-close]")
105
+ page.wait_for_selector(f".lnode[data-lesson='{lessons[0]}'][data-stars='2']") # the map refreshes with the new stars
106
+ assert page.evaluate("location.hash") == "#/subject/math"
107
+ assert page.errors == []
108
+
109
+
110
+ def test_a_link_to_a_round_opens_the_home_under_it(browser, bank):
111
+ app, _, _, lessons = world(bank, 2)
112
+ with running(app) as url:
113
+ page = sign_in(browser, url, f"#/round/{lessons[1]}")
114
+ page.wait_for_selector(".round-layer #ex-q")
115
+ assert page.locator("#lesson-map").count() == 1
116
+ page.click(".mx-x")
117
+ page.wait_for_function("!document.querySelector('.round-layer')")
118
+ assert page.errors == []
mathly2/tests/test_permissions.py CHANGED
@@ -41,6 +41,9 @@ NEEDS = { # (method, path) -> permission
41
  ("PUT", "/api/tests/{test_id}"): "class.teach",
42
  ("DELETE", "/api/tests/{test_id}"): "class.teach",
43
  ("GET", "/api/home"): "path.use",
 
 
 
44
  ("POST", "/api/tests/{test_id}/exercise/{number}/start"): "path.use",
45
  ("POST", "/api/tests/{test_id}/simulation/start"): "path.use",
46
  ("GET", "/api/tests/{test_id}"): "path.use",
@@ -131,7 +134,7 @@ def world():
131
 
132
 
133
  def call(client, method, path, email):
134
- url = path.format(skill_id="t_ext", lesson_id="t", attempt_id="x", test_id="1", number="1", picture_id="x", assignment_id="1", submission_id="1", request_id="x", class_id="1", subject_id="math", index="0", email="someone@school.test", role="teacher")
135
  sends = method in ("POST", "PUT", "PATCH")
136
  return client.request(method, url, headers={"Authorization": f"Bearer dev:{email}"}, json={} if sends else None)
137
 
 
41
  ("PUT", "/api/tests/{test_id}"): "class.teach",
42
  ("DELETE", "/api/tests/{test_id}"): "class.teach",
43
  ("GET", "/api/home"): "path.use",
44
+ ("POST", "/api/lessons/{lesson_id}/round/start"): "path.use",
45
+ ("GET", "/api/rounds/{round_id}"): "path.use",
46
+ ("POST", "/api/rounds/{round_id}/answer"): "path.use",
47
  ("POST", "/api/tests/{test_id}/exercise/{number}/start"): "path.use",
48
  ("POST", "/api/tests/{test_id}/simulation/start"): "path.use",
49
  ("GET", "/api/tests/{test_id}"): "path.use",
 
134
 
135
 
136
  def call(client, method, path, email):
137
+ url = path.format(skill_id="t_ext", lesson_id="t", attempt_id="x", round_id="x", test_id="1", number="1", picture_id="x", assignment_id="1", submission_id="1", request_id="x", class_id="1", subject_id="math", index="0", email="someone@school.test", role="teacher")
138
  sends = method in ("POST", "PUT", "PATCH")
139
  return client.request(method, url, headers={"Authorization": f"Bearer dev:{email}"}, json={} if sends else None)
140
 
mathly2/tests/test_rounds_api.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lesson rounds and the lesson's stars (game-home-lesson-rounds 1.2, 1.3)."""
2
+ from collections import Counter
3
+
4
+ from mathly2.tests.test_skills_api import KID_ID, MON, TUE, auth, raw, right_choice, world, wrong_choice # noqa: F401
5
+
6
+ STAMP = "2026-10-05T10:00:00+00:00"
7
+
8
+
9
+ def skills_of(bank, lesson):
10
+ return [s["id"] for s in bank.skills if s["unit"] == lesson and bank.question_keys(s["id"])]
11
+
12
+
13
+ def a_lesson(bank, n=5):
14
+ return next(l["id"] for l in bank.lessons if len(skills_of(bank, l["id"])) == n)
15
+
16
+
17
+ def start(client, lesson):
18
+ response = client.post(f"/api/lessons/{lesson}/round/start", headers=auth())
19
+ assert response.status_code == 200, response.text
20
+ return response.json()
21
+
22
+
23
+ def play(client, bank, rnd, rights):
24
+ """Answer the round's questions in order, right or wrong; return the last answer's response."""
25
+ got = None
26
+ for right in rights:
27
+ q = rnd["question"]
28
+ skill = next(s["id"] for s in bank.skills if q["key"] in bank.question_keys(s["id"]))
29
+ choice = (right_choice if right else wrong_choice)(bank, skill, q["key"])
30
+ response = client.post(f"/api/rounds/{rnd['id']}/answer", headers=auth(), json={"key": q["key"], "choice": choice})
31
+ assert response.status_code == 200, response.text
32
+ got = response.json()
33
+ rnd = got["round"]
34
+ return got
35
+
36
+
37
+ def map_row(client, lesson):
38
+ home = client.get("/api/home", headers=auth()).json()["map"]
39
+ return home, next(r for r in home["lessons"] if r["id"] == lesson)
40
+
41
+
42
+ def test_a_round_of_five_fresh_skills_is_ten_questions_two_each_and_never_carries_the_answer(world):
43
+ client, _, bank = world
44
+ lesson = a_lesson(bank)
45
+ rnd = start(client, lesson)
46
+ assert rnd["state"] == "open" and rnd["total"] == 10 and rnd["results"] == []
47
+ assert '"correct"' not in str(rnd["question"]).replace("'", '"')
48
+ row = client.app.state.store.get_attempt(rnd["id"])
49
+ assert sorted(Counter(q["skill_id"] for q in row["questions"]).values()) == [2] * 5
50
+ assert start(client, lesson)["id"] == rnd["id"] # an open round resumes
51
+
52
+
53
+ def test_a_round_leaves_out_mastered_skills_at_most_three_from_each(world):
54
+ client, store, bank = world
55
+ lesson = a_lesson(bank)
56
+ skills = skills_of(bank, lesson)
57
+ for s in skills[:4]:
58
+ store.save_progress(KID_ID, s, started_at=STAMP, mastered_at=STAMP)
59
+ rnd = start(client, lesson)
60
+ assert rnd["total"] == 3 and {q["skill_id"] for q in store.get_attempt(rnd["id"])["questions"]} == {skills[4]}
61
+
62
+
63
+ def test_answers_must_come_in_order_and_each_is_a_practice_answer(world):
64
+ client, store, bank = world
65
+ rnd = start(client, a_lesson(bank))
66
+ wrong_key = store.get_attempt(rnd["id"])["questions"][1]["key"]
67
+ assert client.post(f"/api/rounds/{rnd['id']}/answer", headers=auth(), json={"key": wrong_key, "choice": 0}).status_code == 409
68
+ got = play(client, bank, rnd, [False])
69
+ assert got["correct"] is False and got["mistake"] is not None and got["round"]["results"] == [False]
70
+ skill = store.get_attempt(rnd["id"])["questions"][0]["skill_id"]
71
+ assert store.get_progress(KID_ID, skill)["started_at"] # the skill is in learning now
72
+ assert any(r["question_key"] == rnd["question"]["key"] for r in store.review_of(KID_ID)) if hasattr(store, "review_of") else True
73
+
74
+
75
+ def test_the_result_after_the_last_answer_and_the_stars(world):
76
+ client, _, bank = world
77
+ lesson = a_lesson(bank)
78
+ _, row = map_row(client, lesson)
79
+ assert row["stars"] == 0
80
+ got = play(client, bank, start(client, lesson), [True] * 7 + [False] * 3)
81
+ result = got["round"]["result"]
82
+ assert got["round"]["state"] == "done" and result["right"] == 7 and result["total"] == 10 and result["stars"] == 1 and len(result["missed"]) == 3
83
+ assert map_row(client, lesson)[1]["stars"] == 1
84
+ play(client, bank, start(client, lesson), [True] * 8 + [False] * 2)
85
+ assert map_row(client, lesson)[1]["stars"] == 2
86
+ play(client, bank, start(client, lesson), [False] * 10)
87
+ assert map_row(client, lesson)[1]["stars"] == 2 # stars never go down
88
+
89
+
90
+ def test_two_good_rounds_on_two_days_master_the_lesson_three_stars(world):
91
+ client, _, bank = world
92
+ lesson = a_lesson(bank)
93
+ client.set_time(MON)
94
+ first = play(client, bank, start(client, lesson), [True] * 10)["round"]["result"]
95
+ assert first["stars"] == 2 and len(first["almost"]) == 5 and first["mastered"] == []
96
+ client.set_time(TUE)
97
+ last = play(client, bank, start(client, lesson), [True] * 10)
98
+ assert last["round"]["result"]["stars"] == 3 and len(last["round"]["result"]["mastered"]) == 5
99
+ home, row = map_row(client, lesson)
100
+ assert row["stars"] == 3 and home["perfect"] == 1
101
+
102
+
103
+ def test_the_map_names_the_next_lesson_open_round_first(world):
104
+ client, _, bank = world
105
+ home = client.get("/api/home", headers=auth()).json()["map"]
106
+ playable = home["lessons"]
107
+ assert home["next"] == playable[0]["id"] and home["max_stars"] == 3 * len(playable) and home["stars"] == 0
108
+ later = playable[3]["id"]
109
+ start(client, later)
110
+ home, row = map_row(client, later)
111
+ assert home["next"] == later and row["round_open"] is True
openspec/changes/game-home-lesson-rounds/proposal.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Proposal
2
+
3
+ ## Why
4
+
5
+ A parent installed the app for their child: the skills-and-lessons structure is too heavy. A lesson is about 50 exercises (5 skills × a round of 10), which discourages, and the home is a column of cards that does not show at a glance what is done and what is left. The goal is that the child masters the skills; the home should show, simply and like a game, which lessons are done and which are still waiting, and make them want to play the next one. The path stays, for going deeper.
6
+
7
+ ## What Changes
8
+
9
+ - **A lesson round (סבב שיעור):** 10 questions from the lesson's skills, with instant feedback like the exercise screen (a celebration, the mistake's tip). It draws only from skills not mastered yet, at most 3 per skill, so it gets shorter as the lesson fills up (a lesson with one skill left is a round of 3). A lesson whose skills are all mastered draws 10 from all of them. Its answers are practice answers: they count for mastery, the daily goal and the review list.
10
+ - **Stars per lesson:** ⭐ a round finished, ⭐⭐ a round with 8 or more right of 10 (80%), ⭐⭐⭐ every skill of the lesson mastered ("מושלם"). Because mastery needs two days, the third star means coming back another day; a round's result says which skills are one good day away.
11
+ - **The home is a map:** a header with the stars collected, the lessons perfected and the daily goal and streak; one big "play" button for the next lesson; a winding map of the lessons, each a node with its number (a crown when perfect), its stars, and a mark when it is behind the curriculum or has a round in progress. Tapping a node opens its round. The nearest test, the new assignments and the required simulation stay, below the map. The behind card is replaced by the mark on the map.
12
+ - **The path is for going deeper:** its tab is named "העמקה"; nothing else changes on it.
13
+
14
+ ## Capabilities
15
+
16
+ ### New Capabilities
17
+ - `lesson-rounds`: the lesson round and the lesson's stars.
18
+
19
+ ### Modified Capabilities
20
+ - `student-home`: "Home shows only what is critical", "Continue the path", "Lessons behind on home" (replaced by the map).
21
+ - `learning-path`: the tab's name.
22
+
23
+ ## Impact
24
+
25
+ - New `mathly2/rounds.py` (start, read, answer), `mathly2/static/round.js`; `mathly2/skills.py` (`lesson_rows` gives stars and the round facts), `mathly2/realtests.py` (`/api/home` gives the map), `mathly2/static/home.js`, `student.js`, `ui.js`, `app.css`.
26
+ - Database: one widened check on `m2_attempts.kind` (adds `lesson_round`), migration `20261118090000_m2_lesson_rounds.sql`. It must be applied before deploying.
openspec/changes/game-home-lesson-rounds/specs/learning-path/spec.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ ## ADDED Requirements
2
+
3
+ ### Requirement: The path is for going deeper
4
+ The path's tab SHALL be named "העמקה". The home's map is where lessons are played; the path keeps every skill open for practice one by one.
5
+
6
+ #### Scenario: The tab
7
+ - **WHEN** a student looks at the tab bar
8
+ - **THEN** the tab that opens the path reads "העמקה"
openspec/changes/game-home-lesson-rounds/specs/lesson-rounds/spec.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## ADDED Requirements
2
+
3
+ ### Requirement: A lesson round
4
+ Every lesson with practice questions SHALL have a round of at most 10 questions drawn from its skills that are not mastered, at most 3 from each, spread evenly, the least-used questions first. When every skill of the lesson is mastered the round SHALL draw 10 from all its skills. Starting a round while one is open SHALL resume it.
5
+
6
+ #### Scenario: Five skills, none mastered
7
+ - **WHEN** a student starts the round of a lesson with five skills and none mastered
8
+ - **THEN** it has 10 questions, 2 from each skill
9
+
10
+ #### Scenario: One skill left
11
+ - **WHEN** four of the lesson's five skills are mastered
12
+ - **THEN** the round has 3 questions, all from the fifth skill
13
+
14
+ ### Requirement: Instant feedback in a round
15
+ Each answer in a round SHALL be graded at once, with the same feedback as the exercise screen: a celebration for a right answer, the mistake's name and tip for a wrong one, never the right choice. The questions SHALL be answered in order, each once. Every answer SHALL be stored as a practice answer of its skill, not given with help, so it counts for mastery and the daily goal, and a wrong one goes to the review list.
16
+
17
+ #### Scenario: Mastery from a round
18
+ - **WHEN** a student answered a skill's last two questions right yesterday and answers its question in today's round right
19
+ - **THEN** the skill becomes mastered and the answer's feedback says so
20
+
21
+ ### Requirement: A round's result
22
+ After the last answer the round SHALL show the right answers out of its questions, the lesson's stars, the skills mastered in it, the skills that are one good day away from mastery, and each mistake's name and tip. It SHALL offer another round and a way back home.
23
+
24
+ #### Scenario: Seven of ten
25
+ - **WHEN** a student finishes a round with 7 right
26
+ - **THEN** the result shows "7 מתוך 10", one star, and the three mistakes
27
+
28
+ ### Requirement: Stars per lesson
29
+ A lesson SHALL have 0 to 3 stars: 3 when every one of its skills is mastered; else 2 when a finished round had at least 80% right; else 1 when a round was finished; else 0. Stars never go down.
30
+
31
+ #### Scenario: Eight of ten
32
+ - **WHEN** a student finishes a lesson's round with 8 right and some skills not mastered
33
+ - **THEN** the lesson has two stars
openspec/changes/game-home-lesson-rounds/specs/student-home/spec.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## ADDED Requirements
2
+
3
+ ### Requirement: The lessons map
4
+ Home SHALL open with a map of the student's lessons in the subject, in the bank's order, on one winding trail. Each lesson SHALL be a node with its number (a crown and "מושלם" when it has three stars), its name, its stars as ★ and ☆, a "⏰" mark when it is behind the curriculum and a "◐" mark when a round is open. Tapping a node SHALL open the lesson's round over the home. Above the map SHALL be the stars collected out of three per lesson, the lessons perfected, and the daily goal with the streak.
5
+
6
+ #### Scenario: Opening home
7
+ - **WHEN** a student with three lessons opens home
8
+ - **THEN** they see three nodes on the trail, each with its stars, and "⭐ N מתוך 9"
9
+
10
+ #### Scenario: A behind lesson
11
+ - **WHEN** a lesson's date has passed and it is not completed
12
+ - **THEN** its node shows "⏰" with the date
13
+
14
+ ### Requirement: Play the next lesson
15
+ Home SHALL have one big button that opens the round of the next lesson: the lesson with an open round, else the earliest lesson behind the curriculum, else the first lesson with fewer than three stars. With every lesson perfect, it SHALL say so and offer the path for going deeper.
16
+
17
+ #### Scenario: New student
18
+ - **WHEN** a student who answered nothing opens home
19
+ - **THEN** the button opens the round of the first lesson
20
+
21
+ ## MODIFIED Requirements
22
+
23
+ ### Requirement: Continue the path
24
+ Home SHALL have a link to the path, named for going deeper ("להעמקה").
25
+
26
+ #### Scenario: No test
27
+ - **WHEN** a student has no upcoming test
28
+ - **THEN** home shows the map, the play button and the link to the path
29
+
30
+ ### Requirement: Home shows only what is critical
31
+ The home SHALL show only: the lessons map with its header and play button, the nearest test with its readiness, today's task and its practice tests below the pass line, a required simulation, the new assignments (not handed in yet), and the link to the path. Other tests, assignments that were handed in or checked, and settings such as notifications SHALL NOT be on home; the other tests are reached from the test's card.
32
+
33
+ #### Scenario: Nothing critical
34
+ - **WHEN** a student has no upcoming test and no new assignment
35
+ - **THEN** home shows the map, the play button and the link to the path
36
+
37
+ #### Scenario: Two tests
38
+ - **WHEN** a student has two upcoming tests
39
+ - **THEN** home shows the nearest one, and the other is listed on that test's card
40
+
41
+ ## REMOVED Requirements
42
+
43
+ ### Requirement: Lessons behind on home
44
+ **Reason**: The map marks a behind lesson on its node, and the play button opens the earliest behind lesson first.
45
+ **Migration**: None; the quiz stays on the path.
openspec/changes/game-home-lesson-rounds/tasks.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tasks
2
+
3
+ ## 1. Server
4
+
5
+ - [x] 1.1 Migration: `lesson_round` in `m2_attempts.kind`
6
+ - [x] 1.2 `rounds.py`: draw, start or resume, read, answer (a practice answer per question), finish with a grade; tests
7
+ - [x] 1.3 Stars and round facts in `lesson_rows`; `/api/home` gives the map, the next lesson, the stars and the goal; tests
8
+
9
+ ## 2. Screens
10
+
11
+ - [x] 2.1 Home as a map with a header and the play button; the behind card removed
12
+ - [x] 2.2 The round sheet (`#/round/<lesson>`): questions with instant feedback, the result with stars
13
+ - [x] 2.3 The path's tab reads "העמקה"; browser tests updated and added
14
+
15
+ ## 3. Wrap-up
16
+
17
+ - [x] 3.1 Run the full suite