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 +3 -2
- mathly2/main.py +2 -1
- mathly2/quiz.py +5 -3
- mathly2/realtests.py +4 -3
- mathly2/rounds.py +210 -0
- mathly2/skills.py +24 -3
- mathly2/static/app.css +57 -1
- mathly2/static/home.js +64 -16
- mathly2/static/path.js +2 -2
- mathly2/static/round.js +188 -0
- mathly2/static/student.js +9 -1
- mathly2/static/ui.js +3 -2
- mathly2/supabase/migrations/20261118090000_m2_lesson_rounds.sql +9 -0
- mathly2/tests/test_behind_home_browser.py +0 -36
- mathly2/tests/test_home_browser.py +7 -6
- mathly2/tests/test_lesson_map_browser.py +118 -0
- mathly2/tests/test_permissions.py +4 -1
- mathly2/tests/test_rounds_api.py +111 -0
- openspec/changes/game-home-lesson-rounds/proposal.md +26 -0
- openspec/changes/game-home-lesson-rounds/specs/learning-path/spec.md +8 -0
- openspec/changes/game-home-lesson-rounds/specs/lesson-rounds/spec.md +33 -0
- openspec/changes/game-home-lesson-rounds/specs/student-home/spec.md +45 -0
- openspec/changes/game-home-lesson-rounds/tasks.md +17 -0
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**
|
|
|
|
| 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
|
|
|
|
| 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 .
|
|
|
|
| 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"),
|
|
|
|
| 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:
|
| 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:
|
| 2 |
-
//
|
|
|
|
| 3 |
import { swr } from "./api.js";
|
| 4 |
import * as aiSheet from "./ai_sheet.js";
|
| 5 |
import { esc, fmt } from "./math.js";
|
| 6 |
-
import {
|
|
|
|
| 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 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 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>המסלול
|
| 55 |
-
<p class="muted">כ
|
| 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("/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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"' : ""}>מ
|
| 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", "#
|
| 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
|
| 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 |
-
|
| 129 |
-
assert page.
|
|
|
|
|
|
|
| 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
|