3v324v23 commited on
Commit
21b042a
·
1 Parent(s): 107be58

feat(progress): add class progress views for teachers

Browse files

- Introduced a new "התקדמות" tab for teachers to view class progress by lesson, including readiness for upcoming tests.
- Implemented API endpoint `GET /api/classes/{id}/progress` to fetch progress data, including skill states, coverage, and readiness metrics.
- Updated permissions to restrict access to class progress data to the class's teacher and developers only.
- Enhanced UI with period tabs and a class picker, ensuring a responsive design that prevents sideways scrolling.
- Added comprehensive tests for API and browser interactions to validate functionality and permissions.

docs/tasks/prd-mathly.md CHANGED
@@ -78,7 +78,7 @@ reused as files (see 7.1); everything else is rebuilt.
78
  - As a teacher, I want to add and remove the students of my own class, so a new student can start without waiting for a manager.
79
  - As a teacher, I want to set a test date and the lessons it covers, so the app prepares my students for it.
80
  - As a teacher, I want to see who has taken the simulation test.
81
- - As a teacher, I want a heat map of skills per student, so I can see at a glance which skills the class struggles with.
82
  - As a teacher, I want to see how much of each lesson each student has covered.
83
 
84
  **Manager**
@@ -246,14 +246,14 @@ The exercise screen is where a student practices one skill.
246
  - **MST-3 Help does not break the run.** An answer given after opening help is skipped for mastery: it neither counts toward the 3 nor breaks the run.
247
  A wrong answer restarts the run.
248
  - **MST-4 Mastery does not decay.** A mastered skill stays mastered. It does not need re-mastering over time, even after later wrong answers.
249
- The readiness meter, the exercise tests, the heat map and the coverage view all use this state.
250
  - **MST-5 The server decides.** The server stores the date of every answer (the calendar day in Israel, decided by the server, never by the device) and decides mastery from it.
251
  - **MST-6 Daily goal and streak.** 5 correct answers in a day make a streak day. The path shows today's count and the number of days in a row.
252
  A streak is kept through the morning of a new day and counted up to yesterday until the new day's 5 are reached.
253
  - **MST-7 Collection.** A collection screen shows what the student achieved: each mastered skill as a star, each completed lesson (all of its skills mastered)
254
  as a constellation drawn from its stars, and the comets caught (CMT-3).
255
  - **MST-8 Fading stars.** A mastered skill the student has not answered for 14 days has a **faded** star: it dims and says "דועך". One answer on the skill (correct or not) relights it.
256
- Fading is only a review cue: the skill is still mastered (MST-4), so the readiness meter, heat map and exercise tests are unchanged.
257
 
258
  ### 4.8 Comets (CMT)
259
 
@@ -413,14 +413,14 @@ A tab bar at the bottom of the screen leads between them: home, path, submission
413
  and open requests to look again (AI-5). The teacher opens each, sees the AI's suggested score and comment (AI-4) if there is one, gives a grade (0–100) and a note, or sends it back for correction.
414
  - **TCH-5 AI queue.** The teacher sees their classes' AI requests (AI-1), with the picture, and sends or declines each, or sends a whole batch (AI-7).
415
  - **TCH-6 Tests tab.** The teacher sets a test (TST-1) and sees, for each test, which students have taken the simulation test and which have not.
416
- - **TCH-7 Class heat map.**
417
- - Rows: students. Columns: skills (grouped by lesson).
418
- - Each cell shows the skill state: not started / learning / mastered, by color **and** by a symbol or word.
419
- - Tabs for the period: **Sep–Dec** and **Jan–Jun**. A period shows the skills of the lessons taught in it, and states as they were at the end of the period (or today, for the current period).
420
- - **TCH-8 Student lesson coverage.**
421
  - Rows: students. Columns: lessons.
422
- - Each cell shows the % of the lesson's skills the student has mastered, and the student's best quiz (בוחן) grade for that lesson.
423
- - Same period tabs.
 
 
 
 
424
  - **TCH-9 Roster of my class.** For each class they teach, the teacher can paste email addresses to add students and can remove a student (CLS-3, CLS-5). They cannot change the class's name, lessons, teacher or AI budget,
425
  and a teacher cannot add themselves as a student. Removing a student does not delete their progress.
426
 
@@ -441,7 +441,7 @@ A tab bar at the bottom of the screen leads between them: home, path, submission
441
 
442
  *Reference: `app/prompter.py`, `static/prompter.js`, and the management sheet in `static/math.js`.*
443
 
444
- - **DEV-1 Every permission.** A developer can open any class's progress, heat map, coverage, tests, assignments, inbox and AI queue, as the class's teacher can, and everything managers and admins can do.
445
  - **DEV-2 Prompter (פרומפטר).** A developer writes or pastes a text and shows it full screen as a scrolling teleprompter. Saved texts (a title up to 120 characters; up to 200 texts) belong to the account and are on every device; what is being edited is also kept in the browser, so nothing is lost before it is saved.
446
  Keys: space starts and stops, ↑/↓ change speed, +/− change size, M mirrors, F is full screen, Esc leaves; the mouse and a presentation remote work too.
447
  A saved text can have an **outline (ראשי פרקים)**: on request the AI rewrites the text as the same slides with two to four very short topic reminders each, once; it is kept with the text and can be edited. Changing the text without sending an outline drops the outline, so a stale one is never shown.
@@ -462,11 +462,11 @@ A tab bar at the bottom of the screen leads between them: home, path, submission
462
 
463
  ## 6. Design Considerations
464
 
465
- - Hebrew, right-to-left. Works on phones first; the teacher, manager, admin and developer views (heat map, coverage, classes, roles) must also work well on a computer.
466
  - The look follows the path's "sky" theme: a dark-blue night with aurora and starlight accents, gold for mastery, and an owl mascot that perches on the next skill. The app is always dark, as mathly1 is: the page background is a dark night with two soft glows and small fixed stars; it does not move. There is no light mode.
467
  - A button whose action takes longer than about a third of a second shows it (a spinner, a second press ignored), everywhere in the app; with reduced motion it shows "…".
468
  - Math is written in `$…$` text in the exercise bank; the app must render it as formulas.
469
- - State and result never rely on color alone: every state has a symbol or a word (stars, heat map cells, feedback).
470
  - Mastered skills must look clearly different from "learning" and "not started" ones.
471
  - Touch targets are at least 44px. Animations (stars, celebrations, comets) respect the device's reduced-motion setting.
472
  - The "one task for today" on home is a single, obvious button.
@@ -530,7 +530,7 @@ Rebuilt, not copied as a whole; read the places named in each requirement group.
530
  - ≥ 90% of students take the simulation test before each real test.
531
  - On test day, students have mastered on average ≥ 80% of the covered skills.
532
  - ≥ 75% of students take both exercise tests before each real test.
533
- - Teachers open the heat map or the coverage view at least once a week.
534
  - The AI's estimated cost stays within each class's daily budget on every day.
535
 
536
  ## 9. Open Questions
 
78
  - As a teacher, I want to add and remove the students of my own class, so a new student can start without waiting for a manager.
79
  - As a teacher, I want to set a test date and the lessons it covers, so the app prepares my students for it.
80
  - As a teacher, I want to see who has taken the simulation test.
81
+ - As a teacher, I want each student's progress per lesson, so I can see at a glance which lessons the class struggles with.
82
  - As a teacher, I want to see how much of each lesson each student has covered.
83
 
84
  **Manager**
 
246
  - **MST-3 Help does not break the run.** An answer given after opening help is skipped for mastery: it neither counts toward the 3 nor breaks the run.
247
  A wrong answer restarts the run.
248
  - **MST-4 Mastery does not decay.** A mastered skill stays mastered. It does not need re-mastering over time, even after later wrong answers.
249
+ The readiness meter, the exercise tests and the teacher's progress by lesson all use this state.
250
  - **MST-5 The server decides.** The server stores the date of every answer (the calendar day in Israel, decided by the server, never by the device) and decides mastery from it.
251
  - **MST-6 Daily goal and streak.** 5 correct answers in a day make a streak day. The path shows today's count and the number of days in a row.
252
  A streak is kept through the morning of a new day and counted up to yesterday until the new day's 5 are reached.
253
  - **MST-7 Collection.** A collection screen shows what the student achieved: each mastered skill as a star, each completed lesson (all of its skills mastered)
254
  as a constellation drawn from its stars, and the comets caught (CMT-3).
255
  - **MST-8 Fading stars.** A mastered skill the student has not answered for 14 days has a **faded** star: it dims and says "דועך". One answer on the skill (correct or not) relights it.
256
+ Fading is only a review cue: the skill is still mastered (MST-4), so the readiness meter, the teacher's progress and exercise tests are unchanged.
257
 
258
  ### 4.8 Comets (CMT)
259
 
 
413
  and open requests to look again (AI-5). The teacher opens each, sees the AI's suggested score and comment (AI-4) if there is one, gives a grade (0–100) and a note, or sends it back for correction.
414
  - **TCH-5 AI queue.** The teacher sees their classes' AI requests (AI-1), with the picture, and sends or declines each, or sends a whole batch (AI-7).
415
  - **TCH-6 Tests tab.** The teacher sets a test (TST-1) and sees, for each test, which students have taken the simulation test and which have not.
416
+ - **TCH-7 Class progress, by lesson.** The teacher sees progress **per lesson, never per skill**.
 
 
 
 
417
  - Rows: students. Columns: lessons.
418
+ - Each cell is short and shows three things only: a **progress bar** with the percent of the lesson's skills mastered written on it; the student's best quiz (בוחן) grade as a **bare number** (a dash for none, without the word); and, in the current period, a **warning mark** (⚠) when the lesson is behind the curriculum (PTH-11) and not completed. The lesson's state in words (הושלם PTH-10, בלמידה, לא התחילו, בפיגור) is the cell's accessible name, and a completed lesson's bar is gold.
419
+ - Tabs for the period: **Sep–Dec** and **Jan–Jun**. A period shows the lessons of that period (by the lesson's date in the curriculum, PTH-11, else the last month of the bank's month for it), and states as they were at the end of the period (or today, for the current period). The current period opens first.
420
+ - Where: a "התקדמות" tab on the teacher's screen (with its number: the students of the teacher's classes), also reached from a shortcut on each class card; one class at a time, with a picker when the teacher has several. Only the class's teacher and a developer can read it (never a manager or an admin).
421
+ - The table scrolls inside its own frame with the student column fixed; the page does not scroll sideways. A student who has not signed in yet is all "not started".
422
+ - **TCH-8 (merged into TCH-7).** The coverage view is the progress by lesson above; there is no separate per-skill heat map.
423
+ - **TCH-10 Readiness per student.** For the class's nearest coming test: each student's readiness number and word (RDY), mastery percent, practice-test average and simulation grade, a dash for what is missing; a class with no coming test says so.
424
  - **TCH-9 Roster of my class.** For each class they teach, the teacher can paste email addresses to add students and can remove a student (CLS-3, CLS-5). They cannot change the class's name, lessons, teacher or AI budget,
425
  and a teacher cannot add themselves as a student. Removing a student does not delete their progress.
426
 
 
441
 
442
  *Reference: `app/prompter.py`, `static/prompter.js`, and the management sheet in `static/math.js`.*
443
 
444
+ - **DEV-1 Every permission.** A developer can open any class's progress, tests, assignments, inbox and AI queue, as the class's teacher can, and everything managers and admins can do.
445
  - **DEV-2 Prompter (פרומפטר).** A developer writes or pastes a text and shows it full screen as a scrolling teleprompter. Saved texts (a title up to 120 characters; up to 200 texts) belong to the account and are on every device; what is being edited is also kept in the browser, so nothing is lost before it is saved.
446
  Keys: space starts and stops, ↑/↓ change speed, +/− change size, M mirrors, F is full screen, Esc leaves; the mouse and a presentation remote work too.
447
  A saved text can have an **outline (ראשי פרקים)**: on request the AI rewrites the text as the same slides with two to four very short topic reminders each, once; it is kept with the text and can be edited. Changing the text without sending an outline drops the outline, so a stale one is never shown.
 
462
 
463
  ## 6. Design Considerations
464
 
465
+ - Hebrew, right-to-left. Works on phones first; the teacher, manager, admin and developer views (progress by lesson, classes, roles) must also work well on a computer.
466
  - The look follows the path's "sky" theme: a dark-blue night with aurora and starlight accents, gold for mastery, and an owl mascot that perches on the next skill. The app is always dark, as mathly1 is: the page background is a dark night with two soft glows and small fixed stars; it does not move. There is no light mode.
467
  - A button whose action takes longer than about a third of a second shows it (a spinner, a second press ignored), everywhere in the app; with reduced motion it shows "…".
468
  - Math is written in `$…$` text in the exercise bank; the app must render it as formulas.
469
+ - State and result never rely on color alone: every state has a symbol or a word (stars, progress cells, feedback).
470
  - Mastered skills must look clearly different from "learning" and "not started" ones.
471
  - Touch targets are at least 44px. Animations (stars, celebrations, comets) respect the device's reduced-motion setting.
472
  - The "one task for today" on home is a single, obvious button.
 
530
  - ≥ 90% of students take the simulation test before each real test.
531
  - On test day, students have mastered on average ≥ 80% of the covered skills.
532
  - ≥ 75% of students take both exercise tests before each real test.
533
+ - Teachers open the progress view at least once a week.
534
  - The AI's estimated cost stays within each class's daily budget on every day.
535
 
536
  ## 9. Open Questions
mathly2/assignments.py CHANGED
@@ -387,4 +387,6 @@ def teaching_counts(request: Request, user: dict = Depends(need("class.teach")))
387
  tests, assignments = here.tests_of_classes(ids), here.assignments_of_classes(ids)
388
  graded_by_student = [a["id"] for a in assignments if a["kind"] != "exercise_test"]
389
  waiting = sum(1 for s in store.submissions_of_assignments(graded_by_student) if s["state"] == "handed_in")
390
- return {"classes": len(classes), "tests": len(tests), "assignments": len(assignments), "inbox": waiting}
 
 
 
387
  tests, assignments = here.tests_of_classes(ids), here.assignments_of_classes(ids)
388
  graded_by_student = [a["id"] for a in assignments if a["kind"] != "exercise_test"]
389
  waiting = sum(1 for s in store.submissions_of_assignments(graded_by_student) if s["state"] == "handed_in")
390
+ here.prefetch(*[("members", i) for i in ids])
391
+ students = len({m["email"] for i in ids for m in here.members(i)})
392
+ return {"classes": len(classes), "tests": len(tests), "assignments": len(assignments), "inbox": waiting, "progress": students}
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, assignments, classes, cleanup, quizzes, reads, realtests, skills, written
19
  from .config import Settings, load_settings
20
  from .cached import CachedStore
21
  from .store import InMemoryStore, StoreUnavailable, SupabaseStore
@@ -77,6 +77,7 @@ def create_app(settings: Settings | None = None, bank: Bank | None = None, store
77
  app.include_router(skills.router)
78
  app.include_router(quizzes.router)
79
  app.include_router(realtests.router)
 
80
  app.include_router(written.router)
81
  app.include_router(assignments.router)
82
 
 
15
  from .auth import DevAuthenticator, SupabaseAuthenticator, current_user
16
  from .bank import Bank, load_bank
17
 
18
+ from . import accounts, assignments, classes, cleanup, progress, quizzes, reads, realtests, skills, written
19
  from .config import Settings, load_settings
20
  from .cached import CachedStore
21
  from .store import InMemoryStore, StoreUnavailable, SupabaseStore
 
77
  app.include_router(skills.router)
78
  app.include_router(quizzes.router)
79
  app.include_router(realtests.router)
80
+ app.include_router(progress.router)
81
  app.include_router(written.router)
82
  app.include_router(assignments.router)
83
 
mathly2/progress.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A class's progress for its teacher (PRD TCH-7, TCH-10): per lesson, never per skill, with the periods and the readiness per
2
+ student. Nothing is stored: it is all read from the students' progress, quizzes and tests, the lessons' dates and the bank."""
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Request
8
+
9
+ from . import attempts, clock, readiness, reads
10
+ from .access import need
11
+ from .learning import LEARNING, MASTERED, NOT_STARTED, skill_state
12
+ from .realtests import _my_class, facts_for, prime_students, readiness_for
13
+ from .store import _when
14
+
15
+ router = APIRouter(prefix="/api/classes")
16
+
17
+ MONTHS = {"ספטמבר": 9, "אוקטובר": 10, "נובמבר": 11, "דצמבר": 12, "ינואר": 1, "פברואר": 2, "מרץ": 3, "אפריל": 4, "מאי": 5, "יוני": 6}
18
+ PERIODS = ({"id": 1, "label": "ספט׳–דצמ׳"}, {"id": 2, "label": "ינו׳–יוני"})
19
+
20
+
21
+ def period_of_month(month: int) -> int:
22
+ return 1 if 9 <= month <= 12 else 2
23
+
24
+
25
+ def school_year(today: date) -> int:
26
+ """The calendar year in which this school year began (it starts in September)."""
27
+ return today.year if today.month >= 9 else today.year - 1
28
+
29
+
30
+ def period_end(period: int, today: date) -> date:
31
+ year = school_year(today)
32
+ return date(year, 12, 31) if period == 1 else date(year + 1, 6, 30)
33
+
34
+
35
+ def lesson_period(lesson: dict, due: date | None) -> int:
36
+ """By the lesson's date in the curriculum or, without one, by the last month of the bank's month for it."""
37
+ if due:
38
+ return period_of_month(due.month)
39
+ month = MONTHS.get(str(lesson.get("when", "")).split("–")[-1].strip())
40
+ return period_of_month(month) if month else 2
41
+
42
+
43
+ def _day(value) -> date | None:
44
+ if not value:
45
+ return None
46
+ try:
47
+ moment = value if isinstance(value, datetime) else _when(value)
48
+ except ValueError:
49
+ return None
50
+ return clock.school_day(moment) if moment.tzinfo else moment.date()
51
+
52
+
53
+ def state_on(progress: dict | None, end: date, today: date) -> str:
54
+ """The skill's state at the end of a period (today's state while the period has not ended). Mastery never goes away, so the stored times say."""
55
+ if end >= today:
56
+ return skill_state(progress)
57
+ if progress:
58
+ mastered, started = _day(progress.get("mastered_at")), _day(progress.get("started_at"))
59
+ if mastered and mastered <= end:
60
+ return MASTERED
61
+ if (started and started <= end) or (mastered and mastered <= end):
62
+ return LEARNING
63
+ return NOT_STARTED
64
+
65
+
66
+ def best_quiz(rows: list[dict], end: date, today: date) -> int | None:
67
+ """The best grade of the quizzes handed in by the end of the period."""
68
+ grades = [r["grade"] for r in rows if r.get("submitted_at") and r.get("grade") is not None and (end >= today or _day(r["submitted_at"]) <= end)]
69
+ return max(grades) if grades else None
70
+
71
+
72
+ @router.get("/{class_id}/progress")
73
+ def class_progress(class_id: int, request: Request, period: int | None = None, user: dict = Depends(need("class.teach"))) -> dict:
74
+ klass = _my_class(request, user, class_id)
75
+ store, bank, here = request.app.state.store, request.app.state.bank, reads.of(request)
76
+ today = clock.school_day()
77
+ current = period_of_month(today.month)
78
+ if period is None:
79
+ period = current
80
+ if period not in (1, 2):
81
+ raise HTTPException(422, "תקופה לא מוכרת")
82
+ end = period_end(period, today)
83
+ members = store.members(klass["id"])
84
+ ids = [m["user_id"] for m in members if m["user_id"]]
85
+ here.prefetch(("curriculum",), ("tests_of_classes", [klass["id"]]))
86
+ prime_students(request, ids, kinds=("exercise_test", "simulation", "quiz"))
87
+ dates = here.curriculum()
88
+ lessons = [l for l in bank.lessons_with_skills() if lesson_period(l, dates.get(l["id"])) == period]
89
+ now_view = end >= today
90
+ students = []
91
+ for m in members:
92
+ uid = m["user_id"]
93
+ states = {p["skill_id"]: p for p in here.progress_of(uid)} if uid else {}
94
+ quizzes: dict[str, list[dict]] = {}
95
+ for row in (here.attempts_of(uid, "quiz") if uid else []):
96
+ quizzes.setdefault(row["scope"], []).append(row)
97
+ coverage = {}
98
+ for lesson in lessons:
99
+ mine = {s["id"]: state_on(states.get(s["id"]), end, today) for s in lesson["skills"]}
100
+ best = best_quiz(quizzes.get(lesson["id"], []), end, today)
101
+ mastered = sum(1 for v in mine.values() if v == MASTERED)
102
+ passed = any(r.get("submitted_at") and r["grade"] is not None and r["grade"] >= attempts.PASS_GRADE and (now_view or _day(r["submitted_at"]) <= end)
103
+ for r in quizzes.get(lesson["id"], []))
104
+ completed = passed or (bool(mine) and mastered == len(mine))
105
+ due = dates.get(lesson["id"])
106
+ started = best is not None or any(v != NOT_STARTED for v in mine.values())
107
+ coverage[lesson["id"]] = {"state": "completed" if completed else "learning" if started else "not_started",
108
+ "percent": round(100 * mastered / len(mine)) if mine else 0, "best": best, "completed": completed,
109
+ "behind": bool(now_view and due and due < today and not completed)}
110
+ students.append({"email": m["email"], "signed_in": bool(uid), "lessons": coverage})
111
+ nearest = next((t for t in sorted(store.tests_of_classes([klass["id"]]), key=lambda t: t["date"]) if t["date"] >= today), None)
112
+ test = None
113
+ if nearest:
114
+ rows = []
115
+ for m in members:
116
+ facts = facts_for(request, m["user_id"], nearest, today)
117
+ ready = readiness_for(facts)
118
+ rows.append({"email": m["email"], "signed_in": bool(m["user_id"]), "readiness": ready, "mastery": ready["parts"]["M"],
119
+ "practice": ready["parts"]["E"], "simulation": ready["parts"]["S"]})
120
+ names = {l["id"]: l["name"] for l in bank.lessons}
121
+ test = {"id": nearest["id"], "date": nearest["date"].isoformat(), "days_left": (nearest["date"] - today).days,
122
+ "lessons": [names.get(i, i) for i in nearest["lessons"]], "students": rows}
123
+ return {"class": {"id": klass["id"], "name": klass["name"]}, "period": period, "current_period": current, "as_of": min(end, today).isoformat(),
124
+ "periods": [{**p, "current": p["id"] == current} for p in PERIODS],
125
+ "lessons": [{"id": l["id"], "name": l["name"], "due_date": dates[l["id"]].isoformat() if l["id"] in dates else None} for l in lessons],
126
+ "students": students, "test": test}
mathly2/reads.py CHANGED
@@ -40,6 +40,11 @@ class Reads:
40
  return lambda *args: self._read(name, *args)
41
  raise AttributeError(name)
42
 
 
 
 
 
 
43
  def prefetch(self, *calls: tuple) -> None:
44
  """Start the reads (each `(name, *args)`) that are not done yet, together, and wait for all of them. The first error is raised."""
45
  missing = []
 
40
  return lambda *args: self._read(name, *args)
41
  raise AttributeError(name)
42
 
43
+ def prime(self, name: str, args: tuple, value) -> None:
44
+ """Record an answer that was fetched another way (for many students at once), so a later `name(*args)` does not ask again."""
45
+ with self._lock:
46
+ self._done.setdefault(_key(name, args), value)
47
+
48
  def prefetch(self, *calls: tuple) -> None:
49
  """Start the reads (each `(name, *args)`) that are not done yet, together, and wait for all of them. The first error is raised."""
50
  missing = []
mathly2/realtests.py CHANGED
@@ -45,6 +45,24 @@ def _covered(bank, test: dict) -> list[str]:
45
  return [s["id"] for s in bank.skills if s["unit"] in test["lessons"] and bank.question_keys(s["id"])]
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def _student_reads(user_id: str) -> list[tuple]:
49
  return [("progress_of", user_id), ("last_answered", user_id), ("attempts_of", user_id, "exercise_test"), ("attempts_of", user_id, "simulation")]
50
 
@@ -155,7 +173,7 @@ def _checked_lessons(klass: dict, lessons: list[str]) -> list[str]:
155
  def teacher_view(request: Request, test: dict, klass: dict, today: date) -> dict:
156
  store, here = request.app.state.store, reads.of(request)
157
  members = store.members(klass["id"])
158
- here.prefetch(*[call for m in members if m["user_id"] for call in _student_reads(m["user_id"])]) # every student's reads at once
159
  students = []
160
  for m in members:
161
  facts = facts_for(request, m["user_id"], test, today)
 
45
  return [s["id"] for s in bank.skills if s["unit"] in test["lessons"] and bank.question_keys(s["id"])]
46
 
47
 
48
+ def prime_students(request: Request, user_ids: list[str], kinds: tuple[str, ...] = ("exercise_test", "simulation")) -> None:
49
+ """Read what a class's students need (progress, last answers and attempts of each kind) with one call each for all of them, together,
50
+ and hand it to the request's reads, so asking for one student's later does not touch the database."""
51
+ ids = list(dict.fromkeys(user_ids))
52
+ if not ids:
53
+ return
54
+ store, here = request.app.state.store, reads.of(request)
55
+ jobs = {"progress_of": reads.POOL.submit(store.progress_of_users, ids), "last_answered": reads.POOL.submit(store.last_answered_users, ids)}
56
+ jobs.update({("attempts_of", kind): reads.POOL.submit(store.attempts_of_users, ids, kind) for kind in kinds})
57
+ for key, future in jobs.items():
58
+ found = future.result()
59
+ for uid in ids:
60
+ if isinstance(key, tuple):
61
+ here.prime(key[0], (uid, key[1]), found[uid])
62
+ else:
63
+ here.prime(key, (uid,), found[uid])
64
+
65
+
66
  def _student_reads(user_id: str) -> list[tuple]:
67
  return [("progress_of", user_id), ("last_answered", user_id), ("attempts_of", user_id, "exercise_test"), ("attempts_of", user_id, "simulation")]
68
 
 
173
  def teacher_view(request: Request, test: dict, klass: dict, today: date) -> dict:
174
  store, here = request.app.state.store, reads.of(request)
175
  members = store.members(klass["id"])
176
+ prime_students(request, [m["user_id"] for m in members if m["user_id"]]) # every student's reads at once
177
  students = []
178
  for m in members:
179
  facts = facts_for(request, m["user_id"], test, today)
mathly2/static/app.css CHANGED
@@ -436,3 +436,25 @@ ul.students li [dir="ltr"] { overflow-wrap: anywhere; min-width: 0; }
436
  .curriculum li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 12px; padding: 6px 0; border-bottom: 1px solid var(--line); }
437
  .curriculum label { flex: 1 1 200px; }
438
  .curriculum input[type=date] { flex: 0 0 auto; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  .curriculum li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 12px; padding: 6px 0; border-bottom: 1px solid var(--line); }
437
  .curriculum label { flex: 1 1 200px; }
438
  .curriculum input[type=date] { flex: 0 0 auto; }
439
+ /* the teacher's progress tab: heat map, coverage, readiness */
440
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
441
+ #progress select { max-width: 100%; margin-bottom: 8px; }
442
+ .hm { border-collapse: separate; border-spacing: 0; font-size: 13px; min-width: 100%; }
443
+ .hm th, .hm td { border-bottom: 1px solid var(--line); padding: 6px 8px; text-align: center; white-space: nowrap; }
444
+ .hm .lesson-col { white-space: normal; min-width: 90px; max-width: 150px; font-size: 12px; border-inline-start: 1px solid var(--line); }
445
+ .hm .skill-col { font-weight: 400; color: var(--muted); padding: 2px 4px; min-width: 28px; }
446
+ .hm .sticky { position: sticky; inset-inline-start: 0; z-index: 1; background: var(--surface); text-align: start; border-inline-end: 1px solid var(--line); max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
447
+ .hm td.c { width: 28px; min-width: 28px; font-weight: 700; }
448
+ .hm .c-m { background: color-mix(in srgb, var(--sky-gold) 28%, var(--surface)); color: var(--sky-gold); }
449
+ .hm .c-l { background: color-mix(in srgb, var(--sky-accent) 22%, var(--surface)); color: var(--sky-accent); }
450
+ .hm .c-n { color: var(--muted); }
451
+ .legend { margin: 6px 0 0; font-size: 12px; }
452
+ #progress, #progress .tablewrap { min-width: 0; max-width: 100%; }
453
+ #progress .tablewrap { position: relative; } /* so the hidden words inside it (absolute) are clipped by its scrolling, not by the page */
454
+ .hm td.cov { min-width: 84px; padding: 6px 8px; }
455
+ .pbar { position: relative; height: 18px; border-radius: 9px; background: var(--line); overflow: hidden; }
456
+ .pbar i { position: absolute; inset-block: 0; inset-inline-start: 0; background: var(--sky-accent); opacity: .75; }
457
+ .hm td.cov.done .pbar i { background: var(--sky-gold); opacity: 1; }
458
+ .pbar span { position: relative; display: block; font-size: 11px; font-weight: 700; line-height: 18px; text-align: center; color: var(--ink); text-shadow: 0 0 3px var(--surface); }
459
+ .qgrade { margin-top: 3px; font-size: 13px; font-weight: 600; }
460
+ .qgrade .warn { color: var(--sky-danger); }
mathly2/static/teacher.js CHANGED
@@ -9,7 +9,8 @@ const tabs = active => `<div class="subtabs" id="teacher-tabs" role="group" aria
9
  <button type="button" data-tab="classes" aria-pressed="${active === "classes"}">הכיתות שלי <span class="tab-count" data-count="classes"></span></button>
10
  <button type="button" data-tab="tests" aria-pressed="${active === "tests"}">מבחנים <span class="tab-count" data-count="tests"></span></button>
11
  <button type="button" data-tab="assignments" aria-pressed="${active === "assignments"}">משימות <span class="tab-count" data-count="assignments"></span></button>
12
- <button type="button" data-tab="inbox" aria-pressed="${active === "inbox"}">תיבת בדיקה <span class="tab-count" data-count="inbox"></span></button></div>`;
 
13
 
14
  const work = () => import("./teacher_work.js");
15
  const context = { draw: html => draw(html), tabs: active => tabs(active), wire: () => wireTabs() };
@@ -24,7 +25,7 @@ async function fillCounts() {
24
 
25
  function wireTabs() {
26
  fillCounts();
27
- const screens = { classes: showClasses, tests: showTests, assignments: () => work().then(m => m.showAssignments(context)), inbox: () => work().then(m => m.showInbox(context)) };
28
  document.querySelectorAll("#teacher-tabs [data-tab]").forEach(b => b.addEventListener("click", () => screens[b.dataset.tab]()));
29
  }
30
 
@@ -42,7 +43,7 @@ const showClasses = (notes = {}) => swr("/api/teaching", classes => drawClasses(
42
  async function drawClasses(classes, notes) {
43
  draw(`${tabs("classes")}<section id="teaching"><h2>הכיתות שלי</h2>
44
  ${classes.length ? `<div class="cards">${classes.map(c => `<article class="card class-card" data-id="${c.id}">
45
- <div class="hrow"><h3 class="grow">${esc(c.name)}</h3><span class="tag">${c.students.length} תלמידים</span></div>
46
  <p class="muted" style="margin:0 0 8px">${c.ai_enabled ? "AI פועל" : "AI כבוי: מנהל הכיתות מפעיל"}</p>
47
  ${c.students.length ? `<ul class="plain students">${c.students.map(s => `<li><span dir="ltr">${esc(s.email)}</span>
48
  <span class="muted">${s.signed_in ? "נכנס/ה" : "עוד לא נכנס/ה"}</span>
@@ -56,6 +57,7 @@ async function drawClasses(classes, notes) {
56
  <p><button class="primary" type="submit">הוספה</button></p></form></article>`).join("")}</div>`
57
  : `<section class="card"><p class="muted">עוד לא שייכו אותך ככיתה. מנהל הכיתות קובע מי מלמד/ת כל כיתה.</p></section>`}</section>`);
58
  wireTabs();
 
59
  document.querySelectorAll(".class-card").forEach(card => {
60
  const id = Number(card.dataset.id);
61
  card.querySelector("form").addEventListener("submit", async event => {
 
9
  <button type="button" data-tab="classes" aria-pressed="${active === "classes"}">הכיתות שלי <span class="tab-count" data-count="classes"></span></button>
10
  <button type="button" data-tab="tests" aria-pressed="${active === "tests"}">מבחנים <span class="tab-count" data-count="tests"></span></button>
11
  <button type="button" data-tab="assignments" aria-pressed="${active === "assignments"}">משימות <span class="tab-count" data-count="assignments"></span></button>
12
+ <button type="button" data-tab="inbox" aria-pressed="${active === "inbox"}">תיבת בדיקה <span class="tab-count" data-count="inbox"></span></button>
13
+ <button type="button" data-tab="progress" aria-pressed="${active === "progress"}">התקדמות <span class="tab-count" data-count="progress"></span></button></div>`;
14
 
15
  const work = () => import("./teacher_work.js");
16
  const context = { draw: html => draw(html), tabs: active => tabs(active), wire: () => wireTabs() };
 
25
 
26
  function wireTabs() {
27
  fillCounts();
28
+ const screens = { classes: showClasses, tests: showTests, assignments: () => work().then(m => m.showAssignments(context)), inbox: () => work().then(m => m.showInbox(context)), progress: () => import("./teacher_progress.js").then(m => m.show(context)) };
29
  document.querySelectorAll("#teacher-tabs [data-tab]").forEach(b => b.addEventListener("click", () => screens[b.dataset.tab]()));
30
  }
31
 
 
43
  async function drawClasses(classes, notes) {
44
  draw(`${tabs("classes")}<section id="teaching"><h2>הכיתות שלי</h2>
45
  ${classes.length ? `<div class="cards">${classes.map(c => `<article class="card class-card" data-id="${c.id}">
46
+ <div class="hrow"><h3 class="grow">${esc(c.name)}</h3><span class="tag">${c.students.length} תלמידים</span><button type="button" data-progress="${c.id}">התקדמות</button></div>
47
  <p class="muted" style="margin:0 0 8px">${c.ai_enabled ? "AI פועל" : "AI כבוי: מנהל הכיתות מפעיל"}</p>
48
  ${c.students.length ? `<ul class="plain students">${c.students.map(s => `<li><span dir="ltr">${esc(s.email)}</span>
49
  <span class="muted">${s.signed_in ? "נכנס/ה" : "עוד לא נכנס/ה"}</span>
 
57
  <p><button class="primary" type="submit">הוספה</button></p></form></article>`).join("")}</div>`
58
  : `<section class="card"><p class="muted">עוד לא שייכו אותך ככיתה. מנהל הכיתות קובע מי מלמד/ת כל כיתה.</p></section>`}</section>`);
59
  wireTabs();
60
+ document.querySelectorAll("[data-progress]").forEach(b => b.addEventListener("click", () => import("./teacher_progress.js").then(m => m.show(context, Number(b.dataset.progress)))));
61
  document.querySelectorAll(".class-card").forEach(card => {
62
  const id = Number(card.dataset.id);
63
  card.querySelector("form").addEventListener("submit", async event => {
mathly2/static/teacher_progress.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // A class's progress for its teacher, per lesson (never per skill), and the readiness for the coming test, with the two period tabs. One class at a time; the class is chosen here when the teacher has several.
2
+ import { api, swr } from "./api.js";
3
+ import { esc, fmt } from "./math.js";
4
+ import { shortDate } from "./ui.js";
5
+
6
+ const STATE = { completed: ["✓", "הושלם", "m"], learning: ["◐", "בלמידה", "l"], not_started: ["○", "לא התחילו", "n"] };
7
+ const VIEWS = [["lessons", "לפי שיעור"], ["readiness", "מוכנות למבחן"]];
8
+ let chosen = { classId: null, period: null, view: "lessons" }; // what the teacher last looked at
9
+
10
+ const dash = value => value === null || value === undefined ? "—" : value;
11
+ const first = email => esc(email.split("@")[0]);
12
+ const who = s => `<th scope="row" class="sticky"><span dir="ltr" title="${esc(s.email)}">${first(s.email)}</span>${s.signed_in ? "" : ` <span class="muted">(לא נכנס/ה)</span>`}</th>`;
13
+
14
+ function lessonsView(data) {
15
+ if (!data.lessons.length) return `<p class="muted">אין שיעורים בתקופה הזו.</p>`;
16
+ const head = data.lessons.map(l => `<th class="lesson-col">${fmt(l.name)}${l.due_date ? `<br><span class="muted">עד ${shortDate(l.due_date)}</span>` : ""}</th>`).join("");
17
+ const rows = data.students.map(s => `<tr data-student="${esc(s.email)}">${who(s)}${data.lessons.map(l => {
18
+ const c = s.lessons[l.id], word = (STATE[c.state] || STATE.not_started)[1];
19
+ return `<td class="cov${c.completed ? " done" : ""}${c.behind ? " behind" : ""}" data-lesson="${esc(l.id)}" data-state="${esc(c.state)}" aria-label="${word}${c.behind ? ", בפיגור" : ""}">
20
+ <div class="pbar"><i style="width:${c.percent}%"></i><span>${c.percent}%</span></div>
21
+ <div class="qgrade">${dash(c.best)}${c.behind ? ` <span class="warn" data-behind aria-hidden="true">⚠</span>` : ""}</div></td>`;
22
+ }).join("")}</tr>`).join("");
23
+ return `<div class="tablewrap" tabindex="0" role="region" aria-label="התקדמות לפי שיעור"><table class="hm"><thead><tr><th class="sticky">תלמיד</th>${head}</tr></thead><tbody>${rows}</tbody></table></div>
24
+ <p class="muted legend">הסרגל: אחוז המיומנויות שנשלטו (זהב: השיעור הושלם) · המספר: הציון בבוחן · ⚠ בפיגור</p>`;
25
+ }
26
+
27
+ function readiness(data) {
28
+ const t = data.test;
29
+ if (!t) return `<p class="muted" id="no-test">עוד לא נקבע מבחן קרוב לכיתה הזו.</p>`;
30
+ const rows = t.students.map(s => `<tr data-student="${esc(s.email)}">${who(s)}<td><b>${s.readiness.number}</b> · ${esc(s.readiness.label)}</td><td>${dash(s.mastery)}${s.mastery === null ? "" : "%"}</td><td>${dash(s.practice)}</td><td>${dash(s.simulation)}</td></tr>`).join("");
31
+ return `<p>מבחן ב־${shortDate(t.date)} (${t.days_left === 0 ? "היום" : `בעוד ${t.days_left} ימים`}): ${t.lessons.map(esc).join(", ")}</p>
32
+ <div class="tablewrap" tabindex="0" role="region" aria-label="מוכנות"><table class="hm"><thead><tr><th class="sticky">תלמיד</th><th>מוכנות</th><th>שליטה</th><th>תרגול</th><th>סימולציה</th></tr></thead><tbody>${rows}</tbody></table></div>`;
33
+ }
34
+
35
+ function page(ctx, classes, data) {
36
+ const periodTabs = chosen.view === "readiness" ? "" : `<div class="subtabs" id="period-tabs" role="group" aria-label="תקופה">${data.periods.map(p =>
37
+ `<button type="button" data-period="${p.id}" aria-pressed="${p.id === data.period}">${p.label}${p.current ? " (עכשיו)" : ""}</button>`).join("")}</div>`;
38
+ const body = chosen.view === "readiness" ? readiness(data) : lessonsView(data);
39
+ ctx.draw(`${ctx.tabs("progress")}<section id="progress"><h2>התקדמות</h2>
40
+ ${classes.length > 1 ? `<label for="class-pick">כיתה</label><select id="class-pick">${classes.map(c => `<option value="${c.id}"${c.id === data.class.id ? " selected" : ""}>${esc(c.name)}</option>`).join("")}</select>` : `<p class="muted">${esc(data.class.name)}</p>`}
41
+ <div class="subtabs" id="view-tabs" role="group" aria-label="תצוגה">${VIEWS.map(([id, label]) => `<button type="button" data-view="${id}" aria-pressed="${id === chosen.view}">${label}</button>`).join("")}</div>
42
+ ${periodTabs}${body}</section>`);
43
+ ctx.wire();
44
+ document.getElementById("class-pick")?.addEventListener("change", e => show(ctx, Number(e.target.value), null));
45
+ document.getElementById("view-tabs").addEventListener("click", e => {
46
+ const view = e.target.closest("[data-view]")?.dataset.view;
47
+ if (view) { chosen.view = view; page(ctx, classes, data); } // the same data, another view
48
+ });
49
+ document.getElementById("period-tabs")?.addEventListener("click", e => {
50
+ const period = Number(e.target.closest("[data-period]")?.dataset.period);
51
+ if (period) show(ctx, data.class.id, period);
52
+ });
53
+ }
54
+
55
+ /** Open the progress tab for `classId` (the one last looked at, or the first, when not given). */
56
+ export async function show(ctx, classId = null, period = null) {
57
+ const classes = await api("/api/teaching");
58
+ if (!classes.length) {
59
+ ctx.draw(`${ctx.tabs("progress")}<section id="progress"><h2>התקדמות</h2><section class="card"><p class="muted">עוד לא שייכו אותך ככיתה. מנהל הכיתות קובע מי מלמד/ת כל כיתה.</p></section></section>`);
60
+ return ctx.wire();
61
+ }
62
+ const id = classes.some(c => c.id === (classId ?? chosen.classId)) ? (classId ?? chosen.classId) : classes[0].id;
63
+ if (id !== chosen.classId || (classId !== null && period === null)) chosen.period = null;
64
+ chosen = { ...chosen, classId: id, period: period ?? chosen.period };
65
+ await swr(`/api/classes/${id}/progress${chosen.period ? `?period=${chosen.period}` : ""}`, data => page(ctx, classes, data));
66
+ }
mathly2/store.py CHANGED
@@ -270,6 +270,36 @@ class InMemoryStore:
270
  with self._lock:
271
  return [dict(r) for (u, _), r in self._progress.items() if u == user_id]
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  def add_answer(self, user_id: str, skill_id: str, question_key: str, correct: bool, counted: bool,
274
  mistake_id: str | None, day: date) -> None:
275
  with self._lock:
@@ -788,6 +818,36 @@ class SupabaseStore:
788
  def progress_of(self, user_id: str) -> list[dict]:
789
  return self._request("GET", "m2_skill_progress", {"user_id": self._eq(user_id), "select": "*"})
790
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
791
  def add_answer(self, user_id: str, skill_id: str, question_key: str, correct: bool, counted: bool,
792
  mistake_id: str | None, day: date) -> None:
793
  self._request("POST", "m2_answers", body={"user_id": user_id, "skill_id": skill_id, "question_key": question_key,
 
270
  with self._lock:
271
  return [dict(r) for (u, _), r in self._progress.items() if u == user_id]
272
 
273
+ # --- many students at once (a class's progress screen: one call each, not one per student) ---
274
+
275
+ def progress_of_users(self, user_ids) -> dict[str, list[dict]]:
276
+ wanted = set(user_ids)
277
+ with self._lock:
278
+ found: dict[str, list[dict]] = {u: [] for u in dict.fromkeys(user_ids)}
279
+ for (u, _), r in self._progress.items():
280
+ if u in wanted:
281
+ found[u].append(dict(r))
282
+ return found
283
+
284
+ def last_answered_users(self, user_ids) -> dict[str, dict[str, date]]:
285
+ wanted = set(user_ids)
286
+ with self._lock:
287
+ found: dict[str, dict[str, date]] = {u: {} for u in dict.fromkeys(user_ids)}
288
+ for a in self._answers:
289
+ days = found.get(a["user_id"])
290
+ if days is not None and (a["skill_id"] not in days or a["day"] > days[a["skill_id"]]):
291
+ days[a["skill_id"]] = a["day"]
292
+ return found
293
+
294
+ def attempts_of_users(self, user_ids, kind: str) -> dict[str, list[dict]]:
295
+ wanted = set(user_ids)
296
+ with self._lock:
297
+ found: dict[str, list[dict]] = {u: [] for u in dict.fromkeys(user_ids)}
298
+ for r in sorted(self._attempts.values(), key=lambda r: r["started_at"]):
299
+ if r["user_id"] in wanted and r["kind"] == kind:
300
+ found[r["user_id"]].append(_copy_attempt(r))
301
+ return found
302
+
303
  def add_answer(self, user_id: str, skill_id: str, question_key: str, correct: bool, counted: bool,
304
  mistake_id: str | None, day: date) -> None:
305
  with self._lock:
 
818
  def progress_of(self, user_id: str) -> list[dict]:
819
  return self._request("GET", "m2_skill_progress", {"user_id": self._eq(user_id), "select": "*"})
820
 
821
+ # --- many students at once ---
822
+
823
+ @staticmethod
824
+ def _in(ids) -> str:
825
+ return "in.(" + ",".join(dict.fromkeys(ids)) + ")"
826
+
827
+ def progress_of_users(self, user_ids) -> dict[str, list[dict]]:
828
+ ids = list(dict.fromkeys(user_ids))
829
+ found: dict[str, list[dict]] = {u: [] for u in ids}
830
+ if ids:
831
+ for r in self._request("GET", "m2_skill_progress", {"user_id": self._in(ids), "select": "*"}):
832
+ found[str(r["user_id"])].append(r)
833
+ return found
834
+
835
+ def last_answered_users(self, user_ids) -> dict[str, dict[str, date]]:
836
+ ids = list(dict.fromkeys(user_ids))
837
+ found: dict[str, dict[str, date]] = {u: {} for u in ids}
838
+ if ids:
839
+ for r in self._request("GET", "m2_last_answered", {"user_id": self._in(ids), "select": "user_id,skill_id,day"}):
840
+ found[str(r["user_id"])][r["skill_id"]] = date.fromisoformat(r["day"])
841
+ return found
842
+
843
+ def attempts_of_users(self, user_ids, kind: str) -> dict[str, list[dict]]:
844
+ ids = list(dict.fromkeys(user_ids))
845
+ found: dict[str, list[dict]] = {u: [] for u in ids}
846
+ if ids:
847
+ for r in self._request("GET", "m2_attempts", {"user_id": self._in(ids), "kind": self._eq(kind), "select": "*", "order": "started_at"}):
848
+ found[str(r["user_id"])].append(_attempt_row(r))
849
+ return found
850
+
851
  def add_answer(self, user_id: str, skill_id: str, question_key: str, correct: bool, counted: bool,
852
  mistake_id: str | None, day: date) -> None:
853
  self._request("POST", "m2_answers", body={"user_id": user_id, "skill_id": skill_id, "question_key": question_key,
mathly2/tests/test_assignments_api.py CHANGED
@@ -467,7 +467,7 @@ def counts(client, who=TEACHER):
467
 
468
 
469
  def test_the_counts_are_classes_tests_assignments_and_submissions_waiting(world):
470
- assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0}
471
  for _ in range(3):
472
  world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
473
  a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
@@ -478,7 +478,7 @@ def test_the_counts_are_classes_tests_assignments_and_submissions_waiting(world)
478
  submit(world, a["id"], KID)
479
  submit(world, a["id"], KID2)
480
  submit(world, b["id"], KID)
481
- assert counts(world) == {"classes": 1, "tests": 4, "assignments": 4, "inbox": 3}
482
 
483
 
484
  def test_checking_a_submission_lowers_the_inbox_and_removed_items_are_not_counted(world):
@@ -490,13 +490,13 @@ def test_checking_a_submission_lowers_the_inbox_and_removed_items_are_not_counte
490
  assert counts(world)["inbox"] == 0
491
  world.store.remove_test(test["id"])
492
  world.delete(f"/api/assignments/{a['id']}", headers=auth(TEACHER))
493
- assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0}
494
 
495
 
496
  def test_another_teachers_items_are_not_counted_and_a_developer_counts_everything(world):
497
  give(world, kind="quiz", quiz_lesson=world.quiz_lesson)
498
  world.store.create_test(world.b["id"], clock.school_day() + 10 * DAY, [world.other_quiz], OTHER_TEACHER, created_at=clock.now())
499
- assert counts(world, OTHER_TEACHER) == {"classes": 1, "tests": 1, "assignments": 0, "inbox": 0}
500
- assert counts(world) == {"classes": 1, "tests": 0, "assignments": 1, "inbox": 0}
501
- assert counts(world, DEV) == {"classes": 2, "tests": 1, "assignments": 1, "inbox": 0}
502
  assert world.get("/api/teaching/counts", headers=auth(KID)).status_code == 403
 
467
 
468
 
469
  def test_the_counts_are_classes_tests_assignments_and_submissions_waiting(world):
470
+ assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0, "progress": 2}
471
  for _ in range(3):
472
  world.store.create_test(world.a["id"], clock.school_day() + 10 * DAY, [world.quiz_lesson], TEACHER, created_at=clock.now())
473
  a = give(world, kind="quiz", quiz_lesson=world.quiz_lesson).json()
 
478
  submit(world, a["id"], KID)
479
  submit(world, a["id"], KID2)
480
  submit(world, b["id"], KID)
481
+ assert counts(world) == {"classes": 1, "tests": 4, "assignments": 4, "inbox": 3, "progress": 2}
482
 
483
 
484
  def test_checking_a_submission_lowers_the_inbox_and_removed_items_are_not_counted(world):
 
490
  assert counts(world)["inbox"] == 0
491
  world.store.remove_test(test["id"])
492
  world.delete(f"/api/assignments/{a['id']}", headers=auth(TEACHER))
493
+ assert counts(world) == {"classes": 1, "tests": 0, "assignments": 0, "inbox": 0, "progress": 2}
494
 
495
 
496
  def test_another_teachers_items_are_not_counted_and_a_developer_counts_everything(world):
497
  give(world, kind="quiz", quiz_lesson=world.quiz_lesson)
498
  world.store.create_test(world.b["id"], clock.school_day() + 10 * DAY, [world.other_quiz], OTHER_TEACHER, created_at=clock.now())
499
+ assert counts(world, OTHER_TEACHER) == {"classes": 1, "tests": 1, "assignments": 0, "inbox": 0, "progress": 1}
500
+ assert counts(world) == {"classes": 1, "tests": 0, "assignments": 1, "inbox": 0, "progress": 2}
501
+ assert counts(world, DEV) == {"classes": 2, "tests": 1, "assignments": 1, "inbox": 0, "progress": 3}
502
  assert world.get("/api/teaching/counts", headers=auth(KID)).status_code == 403
mathly2/tests/test_permissions.py CHANGED
@@ -64,6 +64,7 @@ NEEDS = { # (method, path) -> permission
64
  ("POST", "/api/classes"): "classes.manage",
65
  ("GET", "/api/classes/{class_id}"): "classes.manage",
66
  ("PUT", "/api/classes/{class_id}"): "classes.manage",
 
67
  ("GET", "/api/curriculum"): "classes.manage",
68
  ("PUT", "/api/curriculum"): "classes.manage",
69
  ("DELETE", "/api/classes/{class_id}"): "classes.manage",
 
64
  ("POST", "/api/classes"): "classes.manage",
65
  ("GET", "/api/classes/{class_id}"): "classes.manage",
66
  ("PUT", "/api/classes/{class_id}"): "classes.manage",
67
+ ("GET", "/api/classes/{class_id}/progress"): "class.teach",
68
  ("GET", "/api/curriculum"): "classes.manage",
69
  ("PUT", "/api/curriculum"): "classes.manage",
70
  ("DELETE", "/api/classes/{class_id}"): "classes.manage",
mathly2/tests/test_progress_api.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A class's progress for its teacher (add-class-progress-views 1.2): heat map, coverage, periods, readiness and who may read them."""
2
+ import time
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from mathly2 import clock
6
+ from mathly2.tests.test_assignments_api import DEV, KID, KID2, MGR, MON, OTHER_TEACHER, OUTSIDER, TEACHER, auth, bank, uid, world # noqa: F401
7
+
8
+ STAMP = "2026-10-05T10:00:00+00:00"
9
+
10
+
11
+ def progress(client, who=TEACHER, period=None, klass=None):
12
+ q = f"?period={period}" if period else ""
13
+ return client.get(f"/api/classes/{(klass or client.a)['id']}/progress{q}", headers=auth(who))
14
+
15
+
16
+ def lesson_of(bank, lesson_id):
17
+ return next(l for l in bank.lessons_with_skills() if l["id"] == lesson_id)
18
+
19
+
20
+ def test_progress_is_per_lesson_with_a_state_and_never_per_skill(world, bank):
21
+ lesson = lesson_of(bank, "t")
22
+ first, second = lesson["skills"][0]["id"], lesson["skills"][1]["id"]
23
+ world.store.save_progress(uid(KID), first, started_at=STAMP, mastered_at=STAMP)
24
+ world.store.save_progress(uid(KID), second, started_at=STAMP)
25
+ world.store.add_member(world.a["id"], "new@school.test") # never signed in
26
+ got = progress(world, period=1).json()
27
+ rows = {s["email"]: s for s in got["students"]}
28
+ assert set(rows) == {KID, KID2, "new@school.test"} and [l["id"] for l in got["lessons"]][0] == "t"
29
+ assert rows[KID]["lessons"]["t"]["state"] == "learning" and rows[KID2]["lessons"]["t"]["state"] == "not_started"
30
+ assert {c["state"] for c in rows["new@school.test"]["lessons"].values()} == {"not_started"} and rows["new@school.test"]["signed_in"] is False
31
+ text = str(got)
32
+ assert not any(k in text for k in ("states", "skills")) and first not in text # nothing stands for a single skill
33
+
34
+
35
+ def test_coverage_is_the_percent_mastered_and_the_best_quiz_and_completed_or_behind(world, bank):
36
+ lesson = lesson_of(bank, "t")
37
+ skills = [s["id"] for s in lesson["skills"]]
38
+ for s in skills[:3]:
39
+ world.store.save_progress(uid(KID), s, started_at=STAMP, mastered_at=STAMP)
40
+ q = world.store.create_attempt(uid(KID), "quiz", "t", MON - timedelta(hours=2), MON - timedelta(hours=1), [])
41
+ world.store.save_attempt(q["id"], submitted_at=MON - timedelta(hours=1), grade=80)
42
+ world.store.set_lesson_dates({"t": clock.school_day() - timedelta(days=3)})
43
+ got = {s["email"]: s["lessons"]["t"] for s in progress(world, period=1).json()["students"]}
44
+ assert got[KID] == {"state": "learning", "percent": round(300 / len(skills)), "best": 80, "completed": False, "behind": True}
45
+ assert got[KID2] == {"state": "not_started", "percent": 0, "best": None, "completed": False, "behind": True}
46
+ for s in skills[3:]: # all mastered: completed, no longer behind
47
+ world.store.save_progress(uid(KID), s, started_at=STAMP, mastered_at=STAMP)
48
+ again = {s["email"]: s["lessons"]["t"] for s in progress(world, period=1).json()["students"]}
49
+ assert again[KID]["percent"] == 100 and again[KID]["state"] == "completed" and again[KID]["completed"] is True and again[KID]["behind"] is False
50
+ q2 = world.store.create_attempt(uid(KID2), "quiz", "t", MON - timedelta(hours=2), MON - timedelta(hours=1), [])
51
+ world.store.save_attempt(q2["id"], submitted_at=MON - timedelta(hours=1), grade=87)
52
+ assert {s["email"]: s["lessons"]["t"] for s in progress(world, period=1).json()["students"]}[KID2]["completed"] is True # a passed quiz completes it
53
+
54
+
55
+ def test_periods_pick_lessons_by_date_or_month_and_show_the_states_as_they_were(world, bank):
56
+ # "t" is a September lesson, "n" a March lesson; a date in the curriculum moves a lesson
57
+ first = progress(world, period=1).json()
58
+ second = progress(world, period=2).json()
59
+ ids1, ids2 = [l["id"] for l in first["lessons"]], [l["id"] for l in second["lessons"]]
60
+ assert "t" in ids1 and "n" in ids2 and "t" not in ids2 and not set(ids1) & set(ids2) and len(ids1) + len(ids2) == 24
61
+ assert first["current_period"] == 1 and first["periods"][0]["current"] is True
62
+ world.store.set_lesson_dates({"t": date_in(2027, 2, 15)})
63
+ assert "t" in [l["id"] for l in progress(world, period=2).json()["lessons"]]
64
+ # a skill mastered on 20 January: not mastered in the first period (ended 31 December), mastered in the second, seen in March
65
+ skill = lesson_of(bank, "n")["skills"][0]["id"]
66
+ world.store.save_progress(uid(KID), skill, started_at="2026-12-01T10:00:00+00:00", mastered_at="2027-01-20T10:00:00+00:00")
67
+ world.set_time(datetime(2027, 3, 10, 9, 0, tzinfo=timezone.utc))
68
+ pct = lambda data, lesson="n": {s["email"]: s["lessons"][lesson] for s in data["students"]}[KID]
69
+ assert pct(progress(world, period=2).json())["percent"] > 0 and pct(progress(world, period=2).json())["state"] == "learning"
70
+ world.store.set_lesson_dates({"n": date_in(2026, 12, 20)}) # move "n" into the first period to see its past state
71
+ past = progress(world, period=1).json()
72
+ assert pct(past)["percent"] == 0 and pct(past)["state"] == "learning" and past["as_of"] == "2026-12-31" # started by then, mastered only in January
73
+
74
+
75
+ def date_in(y, m, d):
76
+ from datetime import date
77
+ return date(y, m, d)
78
+
79
+
80
+ def test_readiness_per_student_for_the_nearest_coming_test_and_none_without(world, bank):
81
+ assert progress(world).json()["test"] is None
82
+ far = world.store.create_test(world.a["id"], clock.school_day() + timedelta(days=30), ["t"], TEACHER, created_at=clock.now())
83
+ near = world.store.create_test(world.a["id"], clock.school_day() + timedelta(days=10), ["t"], TEACHER, created_at=clock.now())
84
+ got = progress(world).json()["test"]
85
+ assert got["id"] == near["id"] and got["days_left"] == 10
86
+ rows = {r["email"]: r for r in got["students"]}
87
+ assert set(rows) == {KID, KID2} and rows[KID]["readiness"]["number"] == 0 and rows[KID]["simulation"] is None and rows[KID]["practice"] is None
88
+ assert far["id"] != near["id"]
89
+
90
+
91
+ def test_only_the_classs_teacher_or_a_developer_may_read_it(world):
92
+ assert progress(world, TEACHER).status_code == 200 and progress(world, DEV).status_code == 200
93
+ assert progress(world, OTHER_TEACHER).status_code == 404 # another teacher's class: not found
94
+ assert progress(world, MGR).status_code == 403 and progress(world, KID).status_code == 403
95
+ assert progress(world, TEACHER, klass=world.b).status_code == 404
96
+ assert progress(world, period=3).status_code == 422
97
+ assert world.get(f"/api/classes/{world.a['id']}/progress").status_code in (401, 403)
98
+
99
+
100
+ def test_a_class_of_twenty_five_needs_no_more_waits_in_a_row_than_a_class_of_two(bank):
101
+ """Every store call waits 0.1 s: the progress screen asks for all students at once, so it takes the same time for 2 as for 25."""
102
+ from fastapi.testclient import TestClient
103
+ from mathly2.config import Settings
104
+ from mathly2.main import create_app
105
+ from mathly2.tests.test_database_waits import DELAY, SlowStore
106
+
107
+ def took(n):
108
+ store = SlowStore()
109
+ klass = store.create_class("setup", "A", lessons=["t"], teacher_email=TEACHER)
110
+ for i in range(n):
111
+ email = f"s{i}@school.test"
112
+ store.add_member(klass["id"], email)
113
+ store.link_user(email, uid(email))
114
+ store.create_test(klass["id"], clock.school_day() + timedelta(days=10), ["t"], TEACHER, created_at=clock.now())
115
+ client = TestClient(create_app(Settings(), bank=bank, store=store))
116
+ client.get("/api/me", headers=auth(TEACHER))
117
+ store.slow = True
118
+ start = time.perf_counter()
119
+ assert client.get(f"/api/classes/{klass['id']}/progress", headers=auth(TEACHER)).status_code == 200
120
+ return time.perf_counter() - start
121
+
122
+ small, big = took(2), took(25)
123
+ assert big < small + 2 * DELAY, (small, big)
124
+
125
+
126
+ def test_the_store_reads_many_students_at_once_and_does_not_mix_them():
127
+ from mathly2.store import InMemoryStore
128
+ store = InMemoryStore()
129
+ a, b, c = "a", "b", "c"
130
+ store.save_progress(a, "s1", started_at=STAMP)
131
+ store.save_progress(b, "s2", started_at=STAMP, mastered_at=STAMP)
132
+ store.save_progress(c, "s3", started_at=STAMP)
133
+ store.add_answer(a, "s1", "s1:k", True, True, None, clock.school_day())
134
+ row = store.create_attempt(a, "quiz", "t", MON, MON + timedelta(minutes=20), [])
135
+ store.create_attempt(c, "quiz", "t", MON, MON + timedelta(minutes=20), [])
136
+ store.create_attempt(a, "simulation", "1", MON, MON + timedelta(minutes=20), [])
137
+ got = store.progress_of_users([a, b])
138
+ assert set(got) == {a, b} and [r["skill_id"] for r in got[a]] == ["s1"] and got[b][0]["mastered_at"] == STAMP
139
+ assert store.progress_of_users([]) == {} and store.progress_of_users(["nobody"]) == {"nobody": []}
140
+ assert list(store.last_answered_users([a, b])) == [a, b] and "s1" in store.last_answered_users([a])[a] and store.last_answered_users([b])[b] == {}
141
+ quizzes = store.attempts_of_users([a, b], "quiz")
142
+ assert [r["id"] for r in quizzes[a]] == [row["id"]] and quizzes[b] == []
mathly2/tests/test_progress_browser.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The teacher's progress tab (add-class-progress-views 2.1), in a real browser."""
2
+ import pytest
3
+
4
+ playwright = pytest.importorskip("playwright.sync_api")
5
+
6
+ from mathly2 import clock
7
+ from mathly2.tests.serve import offline_formulas, running
8
+ from mathly2.tests.test_written_browser import DESKTOP, KID, KID2, PHONE, TEACHER, bank, browser, day, uid, world # noqa: F401
9
+
10
+ STAMP = "2026-10-05T10:00:00+00:00"
11
+
12
+
13
+ def open_progress(browser, url, viewport=PHONE, via="tab"):
14
+ page = offline_formulas(browser.new_page(viewport=viewport))
15
+ errors = []
16
+ page.on("pageerror", lambda e: errors.append(str(e)))
17
+ page.goto(url)
18
+ page.fill("#email", TEACHER)
19
+ page.click("#dev button")
20
+ page.wait_for_selector("#teaching")
21
+ if via == "tab":
22
+ page.click("#teacher-tabs [data-tab=progress]")
23
+ else:
24
+ page.click(".class-card [data-progress]")
25
+ page.wait_for_selector("#progress table, #progress .muted")
26
+ page.errors = errors
27
+ return page
28
+
29
+
30
+ def no_sideways(page):
31
+ assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
32
+
33
+
34
+ @pytest.mark.parametrize("viewport", [PHONE, DESKTOP], ids=["390px", "1280px"])
35
+ def test_progress_is_per_lesson_each_cell_says_its_state_and_the_table_scrolls_inside_its_frame(browser, bank, viewport):
36
+ app, store, klass, lessons = world(bank)
37
+ skills = [s["id"] for s in bank.skills if s["unit"] == "t"]
38
+ store.save_progress(uid(KID), skills[0], started_at=STAMP, mastered_at=STAMP)
39
+ for s in skills:
40
+ store.save_progress(uid(KID2), s, started_at=STAMP, mastered_at=STAMP)
41
+ store.add_member(klass["id"], "new@school.test")
42
+ with running(app) as url:
43
+ page = open_progress(browser, url, viewport)
44
+ assert page.locator("#progress tbody tr").count() == 3 and page.locator("th.skill-col, .hm td.c").count() == 0 # no skill column or cell
45
+ cell = lambda who: page.locator(f"tr[data-student='{who}'] td[data-lesson=t]")
46
+ assert cell(KID).get_attribute("data-state") == "learning" and cell(KID).get_attribute("aria-label") == "בלמידה"
47
+ assert cell(KID2).get_attribute("data-state") == "completed" and cell(KID2).get_attribute("aria-label") == "הושלם" and cell(KID2).inner_text().split() == ["100%", "—"]
48
+ assert cell("new@school.test").get_attribute("data-state") == "not_started" and cell("new@school.test").get_attribute("aria-label") == "לא התחילו"
49
+ assert cell("new@school.test").inner_text().split() == ["0%", "—"] # a bar with its percent and a dash: nothing else
50
+ assert page.locator("td.cov .pbar").count() == page.locator("td.cov").count() and "בוחן" not in page.inner_text("tbody")
51
+ assert page.locator("td.cov:not([data-state])").count() == 0
52
+ assert page.evaluate("(() => { const w = document.querySelector('.tablewrap'); return w.scrollWidth > w.clientWidth; })()") or viewport["width"] > 800
53
+ no_sideways(page)
54
+ assert not page.errors
55
+
56
+
57
+ def test_the_card_shortcut_the_picker_the_views_and_the_periods(browser, bank):
58
+ app, store, klass, lessons = world(bank)
59
+ other = store.create_class("setup", "B", lessons=lessons[:1], teacher_email=TEACHER)
60
+ store.create_test(klass["id"], day(10), ["t"], TEACHER, created_at=clock.now())
61
+ with running(app) as url:
62
+ page = open_progress(browser, url, via="card")
63
+ assert page.locator("#class-pick option").count() == 2 and "לפי שיעור" in page.inner_text("#view-tabs")
64
+ page.click("#period-tabs [data-period='2']")
65
+ page.wait_for_function("document.querySelector('#period-tabs [data-period=\"2\"]').getAttribute('aria-pressed') === 'true'")
66
+ assert page.locator("th.lesson-col").count() > 0 and page.locator("td.cov .pbar").count() > 0
67
+ page.click("#view-tabs [data-view=readiness]")
68
+ assert page.locator("#period-tabs").count() == 0 and "מוכנות" in page.inner_text("#progress table")
69
+ page.select_option("#class-pick", str(other["id"]))
70
+ page.wait_for_selector("#no-test") # class B has no coming test
71
+ no_sideways(page)
72
+
73
+
74
+ def test_the_table_marks_a_completed_and_a_behind_lesson_in_words(browser, bank):
75
+ app, store, klass, lessons = world(bank)
76
+ skills = [s["id"] for s in bank.skills if s["unit"] == "t"]
77
+ for s in skills:
78
+ store.save_progress(uid(KID), s, started_at=STAMP, mastered_at=STAMP)
79
+ store.set_lesson_dates({"t": day(-3)})
80
+ with running(app) as url:
81
+ page = open_progress(browser, url)
82
+ assert page.get_attribute(f"tr[data-student='{KID}'] td[data-lesson=t]", "aria-label") == "הושלם" and page.locator(f"tr[data-student='{KID}'] [data-behind]").count() == 0
83
+ assert page.get_attribute(f"tr[data-student='{KID2}'] td[data-lesson=t]", "aria-label") == "לא התחילו, בפיגור"
84
+ assert page.locator(f"tr[data-student='{KID2}'] td[data-lesson=t] [data-behind]").inner_text() == "⚠" # the visual mark
85
+ assert page.inner_text("#teacher-tabs [data-tab=progress] .tab-count") == "2" # the tab's number: the students
86
+ no_sideways(page)
mathly2/tests/test_written_browser.py CHANGED
@@ -841,11 +841,11 @@ def test_every_teacher_tab_shows_its_number_and_the_numbers_change_after_grading
841
  page.wait_for_selector("#teaching")
842
  page.wait_for_function("document.querySelector('[data-count=inbox]')?.innerText !== ''")
843
  numbers = lambda: {el.get_attribute("data-count"): el.inner_text() for el in page.locator("#teacher-tabs [data-count]").all()}
844
- assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2"}
845
  for tab in ("tests", "assignments", "inbox"): # on every tab
846
  page.click(f"#teacher-tabs [data-tab='{tab}']")
847
  page.wait_for_function("(t) => document.querySelector(`#teacher-tabs [data-tab='${t}'][aria-pressed='true']`) && [...document.querySelectorAll('#teacher-tabs [data-count]')].every(e => e.innerText !== '')", arg=tab)
848
- assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2"}, tab
849
  page.click(f"[data-open='{monday['id']}']")
850
  page.fill("[name=grade]", "80")
851
  page.click("#give-grade")
 
841
  page.wait_for_selector("#teaching")
842
  page.wait_for_function("document.querySelector('[data-count=inbox]')?.innerText !== ''")
843
  numbers = lambda: {el.get_attribute("data-count"): el.inner_text() for el in page.locator("#teacher-tabs [data-count]").all()}
844
+ assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2", "progress": "2"}
845
  for tab in ("tests", "assignments", "inbox"): # on every tab
846
  page.click(f"#teacher-tabs [data-tab='{tab}']")
847
  page.wait_for_function("(t) => document.querySelector(`#teacher-tabs [data-tab='${t}'][aria-pressed='true']`) && [...document.querySelectorAll('#teacher-tabs [data-count]')].every(e => e.innerText !== '')", arg=tab)
848
+ assert numbers() == {"classes": "1", "tests": "3", "assignments": "2", "inbox": "2", "progress": "2"}, tab
849
  page.click(f"[data-open='{monday['id']}']")
850
  page.fill("[name=grade]", "80")
851
  page.click("#give-grade")
openspec/changes/add-class-progress-views/proposal.md CHANGED
@@ -2,26 +2,26 @@
2
 
3
  ## Why
4
 
5
- Teachers need a per-student picture of which skills and lessons each student has covered, to see at a glance where the class struggles (PRD Goal 3, TCH-7, TCH-8).
6
 
7
  ## What Changes
8
 
9
- - A class heat map: students by skills (grouped by lesson), each cell showing the state by color and symbol.
10
- - A lesson coverage table: students by lessons, % of skills mastered and the best quiz grade.
11
- - Period tabs (Sep–Dec, Jan–Jun) showing states as they were at the end of the period.
12
- - Each student's readiness for the class's upcoming test, for the teacher.
13
- - A developer can open these views for any class.
14
 
15
- Screens: `docs/mockups/mathly2-screens.html` (teacher tab: heat map).
16
 
17
  ## Capabilities
18
 
19
  ### New Capabilities
20
- - `class-progress-views`: the heat map, the coverage table, period tabs and readiness per student (PRD TCH-7, TCH-8, RDY-1 for teachers).
21
 
22
  ### Modified Capabilities
23
 
24
  ## Impact
25
 
26
- - Reads `skill-mastery`, `lesson-quizzes` and `readiness-meter` data; no new learning rules.
27
- - Needs each skill's mastered date (already stored) to show states at the end of a period.
 
2
 
3
  ## Why
4
 
5
+ A teacher cannot see where a class stands: which skills each student has covered, which lessons are done or behind the curriculum, and who is ready for the coming test (PRD Goal 3, TCH-7, TCH-8, RDY).
6
 
7
  ## What Changes
8
 
9
+ - A new **"התקדמות" tab** on the teacher's screen (with its number, like the other tabs) and a "התקדמות" shortcut on each class card. It shows one class at a time, with a class picker when the teacher has several.
10
+ - **Progress by lesson** (TCH-7, TCH-8): students by lessons, never by skills. Each cell is kept short: a progress bar with the percent of the lesson's skills mastered, the best quiz grade as a bare number, and a warning mark when the lesson is behind the curriculum (PTH-11). The state in words is the cell's accessible name.
11
+ - **Period tabs**: ספט׳–דצמ׳ and ינו׳–יוני. A period shows the lessons of that period (by the lesson's date in the curriculum, else the bank's month) and states as they were at the end of the period, or today for the current period.
12
+ - **Readiness** for the class's nearest coming test: each student's readiness number and word, mastery, practice average and simulation grade.
13
+ - A developer can open all of these for any class. A manager or an admin cannot: they see no learning data.
14
 
15
+ No learning rule changes. Skills never lose mastery, so a state at a past date is read from the stored started/mastered times.
16
 
17
  ## Capabilities
18
 
19
  ### New Capabilities
20
+ - `class-progress-views`: the progress by lesson, the periods and the readiness per student.
21
 
22
  ### Modified Capabilities
23
 
24
  ## Impact
25
 
26
+ - New `GET /api/classes/{id}/progress`; the counts endpoint gains the number of students; two store reads that fetch many students at once (progress and attempts), so a class costs a few database waits and not one per student; `teacher.js` and a new `teacher_progress.js`; PRD TCH-1, TCH-7, TCH-8; the permissions matrix.
27
+ - Screens: `docs/mockups/mathly2-screens.html` (teacher tab: heat map).
openspec/changes/add-class-progress-views/specs/class-progress-views/spec.md CHANGED
@@ -1,49 +1,68 @@
1
- # Spec Delta
2
 
3
- ## Purpose
 
4
 
5
- Gives a teacher, and a developer, views of a class's progress by skill and by lesson, so they can see at a glance which skills the class struggles with and who needs help.
 
 
6
 
7
- ## ADDED Requirements
 
 
8
 
9
- ### Requirement: Class heat map
10
- For a class they teach, a teacher SHALL see a heat map with a row per student and a column per skill, grouped by lesson. Each cell SHALL show the skill's state (not started, learning, mastered) by color and by a symbol, readable in light and dark mode.
 
11
 
12
- #### Scenario: Reading without color
13
- - **WHEN** the heat map is printed in black and white
14
- - **THEN** every cell's state can still be read from its symbol
15
 
16
- ### Requirement: Lesson coverage
17
- For a class they teach, a teacher SHALL see a table with a row per student and a column per lesson; each cell SHALL show the % of the lesson's skills the student mastered and their best quiz grade for it.
 
18
 
19
- #### Scenario: Half covered
20
- - **WHEN** a student mastered 2 of a lesson's 4 skills and scored 75 on its quiz
21
- - **THEN** their cell for that lesson shows 50% and 75
22
 
23
- ### Requirement: Period tabs
24
- Both views SHALL have tabs for Sep–Dec and Jan–Jun. A period SHALL show the skills of the lessons taught in it, with states as they were at the end of the period, or today for the current period.
 
25
 
26
- #### Scenario: Last period
27
- - **WHEN** a teacher opens the Sep–Dec tab in March
28
- - **THEN** each cell shows the state as of 31 December
29
 
30
- ### Requirement: Readiness per student
31
- For each upcoming test of the class, the teacher SHALL see each student's readiness number and its parts.
 
 
 
 
32
 
33
- #### Scenario: Before a test
34
- - **WHEN** a test is 5 days away
35
- - **THEN** the teacher sees every student's readiness number for it
36
 
37
- ### Requirement: Works on a computer
38
- The views SHALL use the full width of a computer screen and keep student names and lesson headers visible while scrolling.
39
 
40
- #### Scenario: Wide class
41
- - **WHEN** a teacher scrolls the heat map sideways on a computer
42
- - **THEN** the student names stay visible
 
 
 
 
 
 
 
43
 
44
- ### Requirement: Developer access
45
- A developer SHALL be able to open these views for any class.
 
46
 
47
- #### Scenario: Any class
48
- - **WHEN** a developer opens the heat map of a class they do not teach
49
- - **THEN** it opens
 
1
+ ## ADDED Requirements
2
 
3
+ ### Requirement: The progress tab
4
+ A teacher's screen SHALL have a "התקדמות" tab, with the number of students of the teacher's classes like the other tabs, and each class card SHALL have a "התקדמות" shortcut to it. The tab SHALL show one class at a time, with a way to choose the class when the teacher has several. Only a class's own teacher or a developer SHALL be able to read its progress. A manager or an admin MUST NOT, and a teacher MUST NOT read another teacher's class.
5
 
6
+ #### Scenario: Two classes
7
+ - **WHEN** a teacher of two classes opens the tab
8
+ - **THEN** they see the first class and can switch to the other
9
 
10
+ #### Scenario: Another teacher's class
11
+ - **WHEN** a teacher asks for the progress of a class they do not teach, or a manager asks for any class
12
+ - **THEN** the server refuses
13
 
14
+ #### Scenario: A developer
15
+ - **WHEN** a developer opens any class's progress
16
+ - **THEN** they see it as the class's teacher would
17
 
18
+ ### Requirement: Progress by lesson
19
+ The progress view SHALL list the students in rows and the lessons in columns, never the skills. Each cell SHALL show only three things: a progress bar with the percent of the lesson's skills mastered written on it, the best quiz grade as a bare number (a dash for none, without the word "בוחן"), and, in the current period, a warning mark when the lesson is behind the curriculum and not completed. The cell's accessible name SHALL still say the state in words (הושלם, בלמידה, לא התחילו, בפיגור).
 
20
 
21
+ #### Scenario: Cell contents
22
+ - **WHEN** a student has mastered 3 of 5 skills of a lesson and their best quiz grade is 80
23
+ - **THEN** the cell shows a bar with 60% and the number 80, and nothing else
24
 
25
+ #### Scenario: Behind
26
+ - **WHEN** a lesson's date has passed and a student has neither mastered all its skills nor passed its quiz
27
+ - **THEN** that student's cell for the lesson carries the warning mark
28
 
29
+ #### Scenario: Completed or not started
30
+ - **WHEN** a student has passed a lesson's quiz, or has done nothing in it
31
+ - **THEN** the cell's accessible name says "הושלם" or "לא התחילו", and the cell shows no extra text
32
 
33
+ #### Scenario: No skill columns
34
+ - **WHEN** a teacher opens the progress view
35
+ - **THEN** no column or cell stands for a single skill
36
 
37
+ ### Requirement: The progress table
38
+ A student who has not signed in yet SHALL have every lesson not started. The table SHALL scroll inside its own frame with the student column fixed, and the page MUST NOT scroll sideways.
39
+
40
+ #### Scenario: Not signed in yet
41
+ - **WHEN** a student was added to the class and has never signed in
42
+ - **THEN** their row shows every lesson as not started
43
 
44
+ #### Scenario: A phone
45
+ - **WHEN** the table is wider than the screen
46
+ - **THEN** it scrolls inside its frame and the page does not scroll sideways
47
 
48
+ ### Requirement: Periods
49
+ The progress by lesson SHALL have two period tabs, "ספט׳–דצמ׳" and "ינו׳–יוני". A period SHALL show the lessons of that period, by the lesson's date in the curriculum or, when it has none, by the last month of the bank's month for it, and the states as they were at the end of the period, or today for the current period. The current period SHALL be open first.
50
 
51
+ #### Scenario: A past period
52
+ - **WHEN** a skill was mastered on 20 January and the teacher opens the progress view in March
53
+ - **THEN** that lesson's percent does not include the skill in the first period's view (which ends on 31 December), and does in the second
54
+
55
+ #### Scenario: Lessons of the period
56
+ - **WHEN** a lesson is dated 15 February
57
+ - **THEN** it appears in the second period and not in the first
58
+
59
+ ### Requirement: Readiness per student
60
+ For the class's nearest coming test the tab SHALL list each student's readiness number and its word, their mastery percent, their practice-test average and their simulation grade, with a dash for what they have not done. A class with no coming test SHALL say so.
61
 
62
+ #### Scenario: A coming test
63
+ - **WHEN** a class has a test in ten days and one student has done nothing
64
+ - **THEN** the table lists that student with a dash for what is missing
65
 
66
+ #### Scenario: No test
67
+ - **WHEN** the class has no coming test
68
+ - **THEN** the readiness view says no test is set
openspec/changes/add-class-progress-views/tasks.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tasks
2
+
3
+ ## 1. The data
4
+
5
+ - [x] 1.1 Add store reads that fetch the progress rows and the attempts of many students in one call (in memory and Supabase); verify unit tests: rows of several students come back together, an empty list gives nothing, other students are not mixed in
6
+ - [x] 1.2 Add `GET /api/classes/{id}/progress?period=1|2` returning the students, the lessons of the period with their skills and dates, each student's skill states as of the period's end, the coverage (percent, best quiz grade, completed, behind) and the readiness for the nearest coming test; add the number of students to `GET /api/teaching/counts`; verify API tests for every scenario of the progress by lesson, periods and readiness, the permissions (own teacher, other teacher, developer, manager, admin, student) and the permissions matrix; verify a timing test: a class of 25 students needs no more waits in a row than a class of 2
7
+
8
+ ## 2. The screens
9
+
10
+ - [x] 2.1 Add the "התקדמות" tab (with its number) and the card shortcut, the class picker, the period tabs and the two views (progress by lesson, readiness); verify Playwright tests: each cell shows its state in a symbol and a word, no column stands for a skill, the table scrolls inside its frame and the page does not scroll sideways at 360px and 1280px, a student who never signed in is all not started,, the picker switches classes, the period tabs change the lessons shown
11
+
12
+ ## 3. Wrap-up
13
+
14
+ - [x] 3.1 Update the PRD (TCH-1, TCH-7, TCH-8); run the full suite and `openspec validate add-class-progress-views --strict`; verify both pass