Home lists every lesson behind the curriculum with two ways to catch up; archive add-behind-lessons-to-home
Browse files- docs/tasks/prd-mathly.md +1 -1
- mathly2/realtests.py +4 -3
- mathly2/skills.py +28 -4
- mathly2/static/app.css +5 -0
- mathly2/static/home.js +11 -1
- mathly2/tests/test_behind_home_api.py +66 -0
- mathly2/tests/test_behind_home_browser.py +71 -0
- openspec/changes/archive/2026-11-09-add-behind-lessons-to-home/.openspec.yaml +2 -0
- openspec/changes/archive/2026-11-09-add-behind-lessons-to-home/proposal.md +20 -0
- openspec/changes/archive/2026-11-09-add-behind-lessons-to-home/specs/student-home/spec.md +40 -0
- openspec/changes/archive/2026-11-09-add-behind-lessons-to-home/tasks.md +13 -0
- openspec/specs/student-home/spec.md +28 -2
docs/tasks/prd-mathly.md
CHANGED
|
@@ -387,7 +387,7 @@ The exercise screen is where a student practices one skill.
|
|
| 387 |
|
| 388 |
A tab bar at the bottom of the screen leads between them: home, path, submissions (STU-7), grades, collection and review (STU-8).
|
| 389 |
|
| 390 |
-
- **STU-1 Home** shows only what is critical: the nearest test's reminder with its readiness and today's task (TST-3), a required simulation, the **new** assignments (המשימות שלי: not handed in yet), and a button that continues the path at the next skill; my AI requests and their state (AI-1).
|
| 391 |
- **STU-2 Path:** see 4.4.
|
| 392 |
- **STU-3 Grades:** all quiz (best grade), exercise test, simulation and assignment grades.
|
| 393 |
- **STU-4 Collection:** see MST-7.
|
|
|
|
| 387 |
|
| 388 |
A tab bar at the bottom of the screen leads between them: home, path, submissions (STU-7), grades, collection and review (STU-8).
|
| 389 |
|
| 390 |
+
- **STU-1 Home** shows only what is critical: the nearest test's reminder with its readiness and today's task (TST-3), a required simulation, **every lesson behind the curriculum** (PTH-11; earliest date first, each with its two ways to finish it: all its skills mastered, shown as "N מתוך M מיומנויות" and opening the next skill, or its quiz passed with at least 13 of 15, PTH-10, opening the quiz with the best grade so far), the **new** assignments (המשימות שלי: not handed in yet), and a button that continues the path at the next skill; my AI requests and their state (AI-1).
|
| 391 |
- **STU-2 Path:** see 4.4.
|
| 392 |
- **STU-3 Grades:** all quiz (best grade), exercise test, simulation and assignment grades.
|
| 393 |
- **STU-4 Collection:** see MST-7.
|
mathly2/realtests.py
CHANGED
|
@@ -255,8 +255,8 @@ def home(request: Request, user: dict = Depends(learner)) -> dict:
|
|
| 255 |
the required simulation, and the next skill of the path."""
|
| 256 |
here, today, uid = reads.of(request), clock.school_day(), user["id"]
|
| 257 |
class_ids = [c["id"] for c in here.classes_of_student(user["email"])]
|
| 258 |
-
from .skills import RECENT
|
| 259 |
-
here.prefetch(("tests_of_classes", class_ids), ("recent_skills", uid, RECENT), *_student_reads(uid)) # the tests and the student's facts: one wait
|
| 260 |
tests = [t for t in here.tests_of_classes(class_ids) if t["date"] >= today]
|
| 261 |
cards = [card(request, user["id"], t, today) for t in tests]
|
| 262 |
nearest = cards[0] if cards else None
|
|
@@ -264,7 +264,8 @@ def home(request: Request, user: dict = Depends(learner)) -> dict:
|
|
| 264 |
next_skill_row = request.app.state.bank.skill(next_id) if next_id else None
|
| 265 |
return {"tests": cards, "task": {"test_id": nearest["id"], **_with_skill_name(request, nearest["task"])} if nearest else None,
|
| 266 |
"required_simulations": [{"test_id": c["id"], "date": c["date"], "days_left": c["days_left"]} for c in cards if c["simulation"]["required"]],
|
| 267 |
-
"next_skill": {"id": next_id, "name": next_skill_row["name"]} if next_skill_row else None
|
|
|
|
| 268 |
|
| 269 |
|
| 270 |
def _student_test(request: Request, user: dict, test_id: int) -> dict:
|
|
|
|
| 255 |
the required simulation, and the next skill of the path."""
|
| 256 |
here, today, uid = reads.of(request), clock.school_day(), user["id"]
|
| 257 |
class_ids = [c["id"] for c in here.classes_of_student(user["email"])]
|
| 258 |
+
from .skills import RECENT, behind_lessons
|
| 259 |
+
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
|
| 260 |
tests = [t for t in here.tests_of_classes(class_ids) if t["date"] >= today]
|
| 261 |
cards = [card(request, user["id"], t, today) for t in tests]
|
| 262 |
nearest = cards[0] if cards else None
|
|
|
|
| 264 |
next_skill_row = request.app.state.bank.skill(next_id) if next_id else None
|
| 265 |
return {"tests": cards, "task": {"test_id": nearest["id"], **_with_skill_name(request, nearest["task"])} if nearest else None,
|
| 266 |
"required_simulations": [{"test_id": c["id"], "date": c["date"], "days_left": c["days_left"]} for c in cards if c["simulation"]["required"]],
|
| 267 |
+
"next_skill": {"id": next_id, "name": next_skill_row["name"]} if next_skill_row else None,
|
| 268 |
+
"behind": behind_lessons(request, user)}
|
| 269 |
|
| 270 |
|
| 271 |
def _student_test(request: Request, user: dict, test_id: int) -> dict:
|
mathly2/skills.py
CHANGED
|
@@ -8,7 +8,7 @@ import logging
|
|
| 8 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 9 |
from pydantic import BaseModel
|
| 10 |
|
| 11 |
-
from . import 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)
|
|
@@ -211,10 +211,11 @@ def _comet_due(request: Request, user_id: str, today_now: dict, state: dict | No
|
|
| 211 |
return True
|
| 212 |
|
| 213 |
|
| 214 |
-
|
| 215 |
-
|
|
|
|
| 216 |
store, here, uid = request.app.state.store, reads.of(request), user["id"]
|
| 217 |
-
here.prefetch(("progress_of", uid), ("last_answered", uid), ("
|
| 218 |
states = {p["skill_id"]: skill_state(p) for p in here.progress_of(uid)}
|
| 219 |
last, today = here.last_answered(uid), clock.school_day()
|
| 220 |
quizzes_facts = quiz.facts(store, request.app.state.bank, uid, clock.now(), here)
|
|
@@ -236,6 +237,29 @@ def path(request: Request, user: dict = Depends(learner)) -> dict:
|
|
| 236 |
"completed": completed, "passed": passed, "due_date": due.isoformat() if due else None,
|
| 237 |
"behind": bool(due and due < today and not completed),
|
| 238 |
"quiz": {"best": fact.get("best"), "open": bool(fact.get("open")), "due_at": fact["open"]["due_at"].isoformat() if fact.get("open") else None}})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
daily = here.daily_correct(uid)
|
| 240 |
return {"lessons": lessons, "behind": sum(1 for l in lessons if l["behind"]), "next": next_skill(order, states, here.recent_skills(uid, RECENT)),
|
| 241 |
"today": today_progress(daily, today), "streak": streak(daily, today)}
|
|
|
|
| 8 |
from fastapi import 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)
|
|
|
|
| 211 |
return True
|
| 212 |
|
| 213 |
|
| 214 |
+
def lesson_rows(request: Request, user: dict) -> tuple[list[dict], list[str], dict]:
|
| 215 |
+
"""The student's lessons with their skills' states, whether each is completed (all skills mastered or its quiz passed) and behind the curriculum,
|
| 216 |
+
and its quiz state. Shared by the path and the home. Returns (lessons, the skills in the bank's order, the skill states)."""
|
| 217 |
store, here, uid = request.app.state.store, reads.of(request), user["id"]
|
| 218 |
+
here.prefetch(("progress_of", uid), ("last_answered", uid), ("attempts_of", uid, "quiz"), ("curriculum",))
|
| 219 |
states = {p["skill_id"]: skill_state(p) for p in here.progress_of(uid)}
|
| 220 |
last, today = here.last_answered(uid), clock.school_day()
|
| 221 |
quizzes_facts = quiz.facts(store, request.app.state.bank, uid, clock.now(), here)
|
|
|
|
| 237 |
"completed": completed, "passed": passed, "due_date": due.isoformat() if due else None,
|
| 238 |
"behind": bool(due and due < today and not completed),
|
| 239 |
"quiz": {"best": fact.get("best"), "open": bool(fact.get("open")), "due_at": fact["open"]["due_at"].isoformat() if fact.get("open") else None}})
|
| 240 |
+
return lessons, order, states
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def behind_lessons(request: Request, user: dict) -> list[dict]:
|
| 244 |
+
"""The lessons behind the curriculum for the home, the earliest date first: how far the skills are and the next unmastered one, and the quiz."""
|
| 245 |
+
lessons, _, _ = lesson_rows(request, user)
|
| 246 |
+
rows = []
|
| 247 |
+
for l in lessons:
|
| 248 |
+
if not l["behind"]:
|
| 249 |
+
continue
|
| 250 |
+
nxt = next((s for s in l["skills"] if s["state"] != MASTERED), None)
|
| 251 |
+
rows.append({"id": l["id"], "name": l["name"], "due_date": l["due_date"], "mastered": l["mastered"], "total": len(l["skills"]),
|
| 252 |
+
"next_skill": {"id": nxt["id"], "name": nxt["name"]} if nxt else None,
|
| 253 |
+
"quiz": {"best": l["quiz"]["best"], "open": l["quiz"]["open"], "pass_right": attempts.PASS_RIGHT, "size": attempts.QUIZ_SIZE}})
|
| 254 |
+
return sorted(rows, key=lambda r: (r["due_date"], r["id"]))
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
@router.get("/path")
|
| 258 |
+
def path(request: Request, user: dict = Depends(learner)) -> dict:
|
| 259 |
+
here, uid = reads.of(request), user["id"]
|
| 260 |
+
here.prefetch(("progress_of", uid), ("last_answered", uid), ("daily_correct", uid), ("recent_skills", uid, RECENT), ("attempts_of", uid, "quiz"), ("curriculum",))
|
| 261 |
+
lessons, order, states = lesson_rows(request, user)
|
| 262 |
+
today = clock.school_day()
|
| 263 |
daily = here.daily_correct(uid)
|
| 264 |
return {"lessons": lessons, "behind": sum(1 for l in lessons if l["behind"]), "next": next_skill(order, states, here.recent_skills(uid, RECENT)),
|
| 265 |
"today": today_progress(daily, today), "streak": streak(daily, today)}
|
mathly2/static/app.css
CHANGED
|
@@ -478,3 +478,8 @@ ul.students li [dir="ltr"] { overflow-wrap: anywhere; min-width: 0; }
|
|
| 478 |
.review-card.done { opacity: .75; border-color: var(--sky-gold); }
|
| 479 |
.review-card .ex-text { margin: 4px 0 8px; }
|
| 480 |
.review-group [hidden] { display: none; } /* the heading's flex layout would otherwise show it */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
.review-card.done { opacity: .75; border-color: var(--sky-gold); }
|
| 479 |
.review-card .ex-text { margin: 4px 0 8px; }
|
| 480 |
.review-group [hidden] { display: none; } /* the heading's flex layout would otherwise show it */
|
| 481 |
+
/* The lessons behind the curriculum, on the home */
|
| 482 |
+
.behind-list li { border-top: 1px solid var(--line); padding: 8px 0; }
|
| 483 |
+
.behind-list li:first-child { border-top: 0; }
|
| 484 |
+
.behind-list .late { color: var(--sky-danger); font-weight: 600; }
|
| 485 |
+
.behind-list .actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0; }
|
mathly2/static/home.js
CHANGED
|
@@ -66,6 +66,16 @@ function assignmentsCard(all) {
|
|
| 66 |
}).join("")}</ul></section>`;
|
| 67 |
}
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
/** The photo help card (STU-6): only for a class with the AI on; it also lists the student's latest requests with their state. */
|
| 70 |
function aiCard(ai) {
|
| 71 |
if (!ai?.on) return "";
|
|
@@ -84,7 +94,7 @@ function draw(view, data, assignments, ai) {
|
|
| 84 |
view(`${tabs("home")}${backToSubjects()}
|
| 85 |
${first ? nearest(first, data.task, data.next_skill) : `<section class="card" id="no-test"><h2>הבית</h2>
|
| 86 |
<p class="muted">המורה עוד לא קבע מבחן. בינתיים אפשר להמשיך במסלול.</p></section>`}
|
| 87 |
-
${assignmentsCard(assignments)}${aiCard(ai)}
|
| 88 |
${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("")}
|
| 89 |
<p>${data.next_skill ? `<a class="primary-link" id="continue-path" href="#/skill/${encodeURIComponent(data.next_skill.id)}">להמשיך במסלול: ${fmt(data.next_skill.name)} <span aria-hidden="true">‹</span></a>`
|
| 90 |
: `<a class="primary-link" id="continue-path" href="${PATH}">למסלול: כל הכוכבים פתוחים לתרגול <span aria-hidden="true">‹</span></a>`}</p>`);
|
|
|
|
| 66 |
}).join("")}</ul></section>`;
|
| 67 |
}
|
| 68 |
|
| 69 |
+
/** Every lesson behind the curriculum, with the two ways to finish it (PTH-10): all its skills, or its quiz with at least 13 of 15. */
|
| 70 |
+
function behindCard(lessons) {
|
| 71 |
+
if (!lessons?.length) return "";
|
| 72 |
+
return `<section class="card" id="behind-card"><h2>שיעורים בפיגור</h2>
|
| 73 |
+
<ul class="plain behind-list">${lessons.map(l => `<li data-lesson="${esc(l.id)}"><p><b>${fmt(l.name)}</b><br><span class="late">⚠ היה אמור להסתיים ב־${shortDate(l.due_date)}</span></p>
|
| 74 |
+
<p class="actions">${l.next_skill ? `<a class="button" data-way="skills" href="#/skill/${encodeURIComponent(l.next_skill.id)}">לסיים את המיומנויות: ${l.mastered} מתוך ${l.total}</a>` : ""}
|
| 75 |
+
<a class="button" data-way="quiz" href="#/quiz/${encodeURIComponent(l.id)}">${l.quiz.open ? "להמשיך את הבוחן" : "בוחן"}: לפחות ${l.quiz.pass_right} מתוך ${l.quiz.size}</a></p>
|
| 76 |
+
<p class="muted" data-best>${l.quiz.best === null ? "עוד לא נעשה בוחן" : `הציון הטוב ביותר בבוחן: ${l.quiz.best}`}</p></li>`).join("")}</ul></section>`;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
/** The photo help card (STU-6): only for a class with the AI on; it also lists the student's latest requests with their state. */
|
| 80 |
function aiCard(ai) {
|
| 81 |
if (!ai?.on) return "";
|
|
|
|
| 94 |
view(`${tabs("home")}${backToSubjects()}
|
| 95 |
${first ? nearest(first, data.task, data.next_skill) : `<section class="card" id="no-test"><h2>הבית</h2>
|
| 96 |
<p class="muted">המורה עוד לא קבע מבחן. בינתיים אפשר להמשיך במסלול.</p></section>`}
|
| 97 |
+
${behindCard(data.behind)}${assignmentsCard(assignments)}${aiCard(ai)}
|
| 98 |
${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("")}
|
| 99 |
<p>${data.next_skill ? `<a class="primary-link" id="continue-path" href="#/skill/${encodeURIComponent(data.next_skill.id)}">להמשיך במסלול: ${fmt(data.next_skill.name)} <span aria-hidden="true">‹</span></a>`
|
| 100 |
: `<a class="primary-link" id="continue-path" href="${PATH}">למסלול: כל הכוכבים פתוחים לתרגול <span aria-hidden="true">‹</span></a>`}</p>`);
|
mathly2/tests/test_behind_home_api.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lessons behind the curriculum on the home (add-behind-lessons-to-home 1.1)."""
|
| 2 |
+
from datetime import timedelta
|
| 3 |
+
|
| 4 |
+
from mathly2 import clock
|
| 5 |
+
from mathly2.tests.test_quizzes_api import take
|
| 6 |
+
from mathly2.tests.test_skills_api import KID, KID_ID, MON, auth, raw, world # noqa: F401
|
| 7 |
+
|
| 8 |
+
STAMP = "2026-10-05T10:00:00+00:00"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def behind(client):
|
| 12 |
+
return client.get("/api/home", headers=auth()).json()["behind"]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def skills_of(bank, lesson):
|
| 16 |
+
return [s["id"] for s in bank.skills if s["unit"] == lesson]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_every_lesson_behind_is_listed_earliest_date_first_and_others_are_not(world):
|
| 20 |
+
client, store, bank = world
|
| 21 |
+
today = clock.school_day()
|
| 22 |
+
store.set_lesson_dates({"t": today - timedelta(days=2), "t2": today - timedelta(days=9), "k": today + timedelta(days=5), "i": today}) # t2 and t behind; k ahead; i is due today
|
| 23 |
+
got = behind(client)
|
| 24 |
+
assert [r["id"] for r in got] == ["t2", "t"] and got[0]["due_date"] == (today - timedelta(days=9)).isoformat()
|
| 25 |
+
assert all(r["name"] for r in got)
|
| 26 |
+
for s in skills_of(bank, "t2"):
|
| 27 |
+
store.save_progress(KID_ID, s, started_at=STAMP, mastered_at=STAMP)
|
| 28 |
+
assert [r["id"] for r in behind(client)] == ["t"] # completed: no longer listed
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_a_lesson_without_a_date_is_never_listed_and_none_behind_means_an_empty_list(world):
|
| 32 |
+
client, store, bank = world
|
| 33 |
+
assert behind(client) == []
|
| 34 |
+
store.set_lesson_dates({"k": clock.school_day() + timedelta(days=3)})
|
| 35 |
+
assert behind(client) == []
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_the_skills_option_says_how_far_and_opens_the_next_unmastered_skill(world):
|
| 39 |
+
client, store, bank = world
|
| 40 |
+
store.set_lesson_dates({"t": clock.school_day() - timedelta(days=1)})
|
| 41 |
+
skills = skills_of(bank, "t")
|
| 42 |
+
for s in skills[:3]:
|
| 43 |
+
store.save_progress(KID_ID, s, started_at=STAMP, mastered_at=STAMP)
|
| 44 |
+
row = behind(client)[0]
|
| 45 |
+
assert (row["mastered"], row["total"]) == (3, len(skills)) and row["next_skill"]["id"] == skills[3] and row["next_skill"]["name"]
|
| 46 |
+
store.save_progress(KID_ID, skills[1], mastered_at=None) # a skill not mastered in the middle comes first
|
| 47 |
+
assert behind(client)[0]["next_skill"]["id"] == skills[1]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_the_quiz_option_shows_the_best_grade_and_what_is_needed_and_a_pass_catches_the_lesson_up(world):
|
| 51 |
+
client, store, bank = world
|
| 52 |
+
store.set_lesson_dates({"t": clock.school_day() - timedelta(days=1)})
|
| 53 |
+
assert behind(client)[0]["quiz"] == {"best": None, "open": False, "pass_right": 13, "size": 15}
|
| 54 |
+
take(client, bank, "t", [True] * 12 + [False] * 3) # 80
|
| 55 |
+
quiz = behind(client)[0]["quiz"]
|
| 56 |
+
assert quiz["best"] == 80 and quiz["pass_right"] == 13 and quiz["size"] == 15
|
| 57 |
+
take(client, bank, "t", [True] * 13 + [False] * 2) # passed
|
| 58 |
+
assert behind(client) == []
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_the_path_and_the_home_agree_on_what_is_behind(world):
|
| 62 |
+
client, store, bank = world
|
| 63 |
+
today = clock.school_day()
|
| 64 |
+
store.set_lesson_dates({"t": today - timedelta(days=4), "t2": today - timedelta(days=1)})
|
| 65 |
+
path = client.get("/api/path", headers=auth()).json()
|
| 66 |
+
assert {l["id"] for l in path["lessons"] if l["behind"]} == {r["id"] for r in behind(client)} == {"t", "t2"} and path["behind"] == 2
|
mathly2/tests/test_behind_home_browser.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The card of lessons behind the curriculum on the home (add-behind-lessons-to-home 2.1), in a real browser."""
|
| 2 |
+
from datetime import datetime, timedelta, timezone
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
playwright = pytest.importorskip("playwright.sync_api")
|
| 7 |
+
|
| 8 |
+
from mathly2 import clock
|
| 9 |
+
from mathly2.tests.serve import running
|
| 10 |
+
from mathly2.tests.test_home_browser import KID_ID, PHONE, bank, browser, no_sideways_scroll, sign_in, to_home, world # noqa: F401
|
| 11 |
+
|
| 12 |
+
STAMP = "2026-10-05T10:00:00+00:00"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def seed(app, store, bank, lessons):
|
| 16 |
+
today = clock.school_day()
|
| 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_with_both_ways_to_finish(browser, bank):
|
| 21 |
+
app, store, klass, lessons = world(bank)
|
| 22 |
+
seed(app, store, bank, lessons)
|
| 23 |
+
skills = [s["id"] for s in bank.skills if s["unit"] == lessons[0]]
|
| 24 |
+
store.save_progress(KID_ID, skills[0], started_at=STAMP, mastered_at=STAMP)
|
| 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 "היה אמור להסתיים ב־" in rows.nth(0).inner_text()
|
| 32 |
+
mine = page.locator(f"#behind-card li[data-lesson='{lessons[0]}']")
|
| 33 |
+
assert f"1 מתוך {len(skills)}" in mine.locator("[data-way=skills]").inner_text() and mine.locator("[data-way=skills]").get_attribute("href") == f"#/skill/{skills[1]}"
|
| 34 |
+
assert "לפחות 13 מתוך 15" in mine.locator("[data-way=quiz]").inner_text() and mine.locator("[data-way=quiz]").get_attribute("href") == f"#/quiz/{lessons[0]}"
|
| 35 |
+
assert "עוד לא נעשה בוחן" in mine.locator("[data-best]").inner_text()
|
| 36 |
+
no_sideways_scroll(page)
|
| 37 |
+
assert not page.errors
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_the_best_quiz_grade_shows_and_the_links_open_the_skill_and_the_quiz(browser, bank):
|
| 41 |
+
app, store, klass, lessons = world(bank)
|
| 42 |
+
seed(app, store, bank, lessons)
|
| 43 |
+
q = store.create_attempt(KID_ID, "quiz", lessons[0], clock.now() - timedelta(hours=2), clock.now() - timedelta(hours=1), [])
|
| 44 |
+
store.save_attempt(q["id"], submitted_at=clock.now() - timedelta(hours=1), grade=80)
|
| 45 |
+
with running(app) as url:
|
| 46 |
+
page = sign_in(browser, url)
|
| 47 |
+
to_home(page)
|
| 48 |
+
page.wait_for_selector("#behind-card")
|
| 49 |
+
assert "הציון הטוב ביותר בבוחן: 80" in page.inner_text(f"#behind-card li[data-lesson='{lessons[0]}'] [data-best]")
|
| 50 |
+
page.click(f"#behind-card li[data-lesson='{lessons[1]}'] [data-way=skills]")
|
| 51 |
+
page.wait_for_selector(".mx-sheet")
|
| 52 |
+
assert page.evaluate("location.hash").startswith("#/skill/")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_no_card_when_no_lesson_is_behind_or_when_it_was_passed(browser, bank):
|
| 56 |
+
app, store, klass, lessons = world(bank)
|
| 57 |
+
store.set_lesson_dates({lessons[0]: clock.school_day() + timedelta(days=5)})
|
| 58 |
+
with running(app) as url:
|
| 59 |
+
page = sign_in(browser, url)
|
| 60 |
+
to_home(page)
|
| 61 |
+
page.wait_for_selector("#continue-path")
|
| 62 |
+
assert page.locator("#behind-card").count() == 0
|
| 63 |
+
app, store, klass, lessons = world(bank)
|
| 64 |
+
store.set_lesson_dates({lessons[0]: clock.school_day() - timedelta(days=1)})
|
| 65 |
+
q = store.create_attempt(KID_ID, "quiz", lessons[0], clock.now() - timedelta(hours=2), clock.now() - timedelta(hours=1), [])
|
| 66 |
+
store.save_attempt(q["id"], submitted_at=clock.now() - timedelta(hours=1), grade=87) # 13 of 15: passed
|
| 67 |
+
with running(app) as url:
|
| 68 |
+
page = sign_in(browser, url)
|
| 69 |
+
to_home(page)
|
| 70 |
+
page.wait_for_selector("#continue-path")
|
| 71 |
+
assert page.locator("#behind-card").count() == 0
|
openspec/changes/archive/2026-11-09-add-behind-lessons-to-home/.openspec.yaml
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
schema: spec-driven
|
| 2 |
+
created: 2026-11-09
|
openspec/changes/archive/2026-11-09-add-behind-lessons-to-home/proposal.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Proposal
|
| 2 |
+
|
| 3 |
+
## Why
|
| 4 |
+
|
| 5 |
+
A student who is behind the curriculum sees it only as a small mark on the path. Being behind is critical, so the home should say which lessons are behind and offer the two ways to catch up.
|
| 6 |
+
|
| 7 |
+
## What Changes
|
| 8 |
+
|
| 9 |
+
- **Home lists every lesson that is behind the curriculum** (its date has passed and it is not completed), the earliest date first, in a card "שיעורים בפיגור".
|
| 10 |
+
- **Each lesson offers two ways to finish it** (PTH-10): finish all its skills ("N מתוך M מיומנויות", opening the next unmastered skill), or pass its בוחן with at least 13 right out of 15 (opening the quiz, showing the best grade so far).
|
| 11 |
+
- A lesson that is completed, has no date or whose date is ahead is not listed, and with no lesson behind there is no card.
|
| 12 |
+
|
| 13 |
+
## Capabilities
|
| 14 |
+
|
| 15 |
+
### Modified Capabilities
|
| 16 |
+
- `student-home`: "Home shows only what is critical", and a new "Lessons behind on home".
|
| 17 |
+
|
| 18 |
+
## Impact
|
| 19 |
+
|
| 20 |
+
- `GET /api/home` gains the behind lessons (the path's lesson facts are shared, not computed twice); `home.js`; PRD STU-1. No database change.
|
openspec/changes/archive/2026-11-09-add-behind-lessons-to-home/specs/student-home/spec.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## MODIFIED Requirements
|
| 2 |
+
|
| 3 |
+
### Requirement: Home shows only what is critical
|
| 4 |
+
The home SHALL show only: the nearest test with its readiness and today's task, a required simulation, the lessons behind the curriculum, the new assignments (not handed in yet), and the button that continues 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.
|
| 5 |
+
|
| 6 |
+
#### Scenario: Nothing critical
|
| 7 |
+
- **WHEN** a student has no upcoming test, no new assignment and no lesson behind
|
| 8 |
+
- **THEN** home shows only the button that continues the path
|
| 9 |
+
|
| 10 |
+
#### Scenario: Two tests
|
| 11 |
+
- **WHEN** a student has two upcoming tests
|
| 12 |
+
- **THEN** home shows the nearest one, and the other is listed on that test's card
|
| 13 |
+
|
| 14 |
+
## ADDED Requirements
|
| 15 |
+
|
| 16 |
+
### Requirement: Lessons behind on home
|
| 17 |
+
Home SHALL list every lesson behind the curriculum, the earliest date first, with its name and the date it should have been completed by. A completed lesson, a lesson with no date and a lesson whose date is ahead SHALL NOT be listed, and with none behind there SHALL be no such card.
|
| 18 |
+
|
| 19 |
+
#### Scenario: Two lessons behind
|
| 20 |
+
- **WHEN** two lessons have passed their dates and are not completed
|
| 21 |
+
- **THEN** home lists both, the one with the earlier date first
|
| 22 |
+
|
| 23 |
+
#### Scenario: Caught up
|
| 24 |
+
- **WHEN** a student passes the בוחן of a lesson that was behind
|
| 25 |
+
- **THEN** the lesson is no longer listed on home
|
| 26 |
+
|
| 27 |
+
#### Scenario: None behind
|
| 28 |
+
- **WHEN** no lesson is behind
|
| 29 |
+
- **THEN** home has no behind card
|
| 30 |
+
|
| 31 |
+
### Requirement: Two ways to catch up
|
| 32 |
+
Each lesson listed as behind SHALL offer two ways to finish it: all its skills mastered, shown as "N מתוך M מיומנויות" and opening its next unmastered skill; or its בוחן passed with at least 13 right out of 15, opening the בוחן and showing the best grade so far.
|
| 33 |
+
|
| 34 |
+
#### Scenario: Skills
|
| 35 |
+
- **WHEN** a student has mastered 3 of 5 skills of a lesson that is behind
|
| 36 |
+
- **THEN** its row says "3 מתוך 5 מיומנויות" and the skills option opens the first skill not mastered
|
| 37 |
+
|
| 38 |
+
#### Scenario: The בוחן
|
| 39 |
+
- **WHEN** the student's best grade in that lesson's בוחן is 80
|
| 40 |
+
- **THEN** the row shows 80 and says that 13 right out of 15 is needed
|
openspec/changes/archive/2026-11-09-add-behind-lessons-to-home/tasks.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Tasks
|
| 2 |
+
|
| 3 |
+
## 1. The data
|
| 4 |
+
|
| 5 |
+
- [x] 1.1 Share the path's lesson facts with home and add the behind lessons to `GET /api/home` (name, date, mastered and total skills, the next unmastered skill, the בוחן's best grade and whether one is open), earliest date first; verify API tests for every scenario of "Lessons behind on home", and a timing test that home needs no more waits in a row than before
|
| 6 |
+
|
| 7 |
+
## 2. The screen
|
| 8 |
+
|
| 9 |
+
- [x] 2.1 Show the card "שיעורים בפיגור" on home with the two options per lesson; verify Playwright tests: the card lists the lessons in date order with both options and the links open the next skill and the בוחן, the best grade and "13 מתוך 15" show, no card when none is behind, no sideways scrolling at 360px
|
| 10 |
+
|
| 11 |
+
## 3. Wrap-up
|
| 12 |
+
|
| 13 |
+
- [x] 3.1 Update the PRD (STU-1); run the full suite and `openspec validate add-behind-lessons-to-home --strict`; verify both pass
|
openspec/specs/student-home/spec.md
CHANGED
|
@@ -38,12 +38,38 @@ The student's screens (home, path, submissions "הגשות", grades, collection)
|
|
| 38 |
- **THEN** the submissions screen opens
|
| 39 |
|
| 40 |
### Requirement: Home shows only what is critical
|
| 41 |
-
The home SHALL show only: the nearest test with its readiness and today's task, a required simulation, the new assignments (not handed in yet), and the button that continues 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.
|
| 42 |
|
| 43 |
#### Scenario: Nothing critical
|
| 44 |
-
- **WHEN** a student has no upcoming test
|
| 45 |
- **THEN** home shows only the button that continues the path
|
| 46 |
|
| 47 |
#### Scenario: Two tests
|
| 48 |
- **WHEN** a student has two upcoming tests
|
| 49 |
- **THEN** home shows the nearest one, and the other is listed on that test's card
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
- **THEN** the submissions screen opens
|
| 39 |
|
| 40 |
### Requirement: Home shows only what is critical
|
| 41 |
+
The home SHALL show only: the nearest test with its readiness and today's task, a required simulation, the lessons behind the curriculum, the new assignments (not handed in yet), and the button that continues 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.
|
| 42 |
|
| 43 |
#### Scenario: Nothing critical
|
| 44 |
+
- **WHEN** a student has no upcoming test, no new assignment and no lesson behind
|
| 45 |
- **THEN** home shows only the button that continues the path
|
| 46 |
|
| 47 |
#### Scenario: Two tests
|
| 48 |
- **WHEN** a student has two upcoming tests
|
| 49 |
- **THEN** home shows the nearest one, and the other is listed on that test's card
|
| 50 |
+
|
| 51 |
+
### Requirement: Lessons behind on home
|
| 52 |
+
Home SHALL list every lesson behind the curriculum, the earliest date first, with its name and the date it should have been completed by. A completed lesson, a lesson with no date and a lesson whose date is ahead SHALL NOT be listed, and with none behind there SHALL be no such card.
|
| 53 |
+
|
| 54 |
+
#### Scenario: Two lessons behind
|
| 55 |
+
- **WHEN** two lessons have passed their dates and are not completed
|
| 56 |
+
- **THEN** home lists both, the one with the earlier date first
|
| 57 |
+
|
| 58 |
+
#### Scenario: Caught up
|
| 59 |
+
- **WHEN** a student passes the בוחן of a lesson that was behind
|
| 60 |
+
- **THEN** the lesson is no longer listed on home
|
| 61 |
+
|
| 62 |
+
#### Scenario: None behind
|
| 63 |
+
- **WHEN** no lesson is behind
|
| 64 |
+
- **THEN** home has no behind card
|
| 65 |
+
|
| 66 |
+
### Requirement: Two ways to catch up
|
| 67 |
+
Each lesson listed as behind SHALL offer two ways to finish it: all its skills mastered, shown as "N מתוך M מיומנויות" and opening its next unmastered skill; or its בוחן passed with at least 13 right out of 15, opening the בוחן and showing the best grade so far.
|
| 68 |
+
|
| 69 |
+
#### Scenario: Skills
|
| 70 |
+
- **WHEN** a student has mastered 3 of 5 skills of a lesson that is behind
|
| 71 |
+
- **THEN** its row says "3 מתוך 5 מיומנויות" and the skills option opens the first skill not mastered
|
| 72 |
+
|
| 73 |
+
#### Scenario: The בוחן
|
| 74 |
+
- **WHEN** the student's best grade in that lesson's בוחן is 80
|
| 75 |
+
- **THEN** the row shows 80 and says that 13 right out of 15 is needed
|