3v324v23 commited on
Commit
01f0f9e
·
1 Parent(s): 0b390f8

Enhance scoring logic in class practice tests

Browse files

- Added assertions to verify the handling of recordings and tries for classes based on their opening times.
- Implemented checks for unmatched recordings and their impact on scoring summaries.
- Ensured that the state of student records reflects accurate counts of best scores, tries, and unmatched recordings.
- Updated tests to cover scenarios where classes are opened after recordings are made.

Files changed (5) hide show
  1. README.md +3 -3
  2. app/auth.py +3 -2
  3. app/main.py +29 -9
  4. static/index.html +305 -59
  5. tests/test_scoring.py +17 -0
README.md CHANGED
@@ -100,7 +100,7 @@ The page's "ההתקדמות שלי" button reads these rows back through `GET /
100
 
101
  ### The teacher
102
 
103
- `TEACHER_EMAILS` names the teacher's Google account (several are separated by commas or spaces). After sign-in a teacher chooses between **תרגול**, the same practice a student gets, and **ניהול**, the management panel. The panel lists every student with their usage (recordings, those of the last 7 days, days practised, last practice), progress (sentences practised) and grade (the average of the best scores), and a student opens on the same figures per topic.
104
 
105
  ### Levels
106
 
@@ -108,9 +108,9 @@ The course has levels, each with a planned number of lessons (`LEVELS` in `app/w
108
 
109
  ### Classes
110
 
111
- In the panel the teacher opens a class: a name such as "מתחילים אוקטובר 2026", the lessons it studies (by level: a level's box ticks all of it; a lesson not written yet can be ticked too, and opens to the class once it is), and its students, a line each with the Google email and an optional nickname (`dana@gmail.com דנה`). A class is edited or deleted from the same dialog. A student may be in several classes: after the opening screen the page asks which one they practise in now, and the learning path shows that class's lessons (a teacher, and anyone before the first class, sees every lesson and is not asked). The page shows the class under the app's name, and pressing it lists the class's students, with a button to move to another class; each student appears by their nickname in it or else their Google name (no addresses; `GET /api/classmates`). Once there is a class, only the classes' students and the teachers can sign in; before the first one, any Google account can.
112
 
113
- The panel has a tab per class with its figures, its lessons (each with the sentences the class finds hard: the class average of each student's best score under 70) and its students, with a column per lesson of the class (per level, past 4 lessons). Before the first class it shows every account that signed in. This replaces the former `ALLOWED_EMAILS`, which is no longer read. The lessons limit what the path shows; the sentences themselves stay public, as before.
114
 
115
  ### Homework
116
 
 
100
 
101
  ### The teacher
102
 
103
+ `TEACHER_EMAILS` names the teacher's Google account (several are separated by commas or spaces). After sign-in a teacher chooses between **תרגול**, the same practice a student gets, and **ניהול**, the management panel. The panel lists every student with their usage (recordings, those of the last 7 days, days practised, last practice), progress (sentences practised) and grade (the average of the best scores), and pressing a student opens their own screen in place of the class's: their figures, their homework, every lesson of the class with its topics, and their best and weakest sentences, all counted as the class counts. "חזרה לכיתה", or the browser's back button, returns to the class.
104
 
105
  ### Levels
106
 
 
108
 
109
  ### Classes
110
 
111
+ In the panel the teacher opens a class in five steps, a question each: its name (such as "מתחילים אוקטובר 2026"), its level (the class studies all the level's lessons; one not written yet opens to the class once it is), its students (ticked from the accounts the panel knows, or added by Google email, each with an optional nickname), its homework, and a summary to confirm, which says where all of it is changed later. That is the class's edit dialog ("עריכה", beside the class in the panel's list of classes): the name, the lessons one by one (by level: a level's box ticks all of it), the homework, and the students, a line each with the Google email and an optional nickname (`dana@gmail.com דנה`); the class is deleted there too. A student may be in several classes: after the opening screen the page asks which one they practise in now, and the learning path shows that class's lessons (a teacher, and anyone before the first class, sees every lesson and is not asked). The page shows the class under the app's name, and pressing it lists the class's students, with a button to move to another class; each student appears by their nickname in it or else their Google name (no addresses; `GET /api/classmates`). Once there is a class, only the classes' students and the teachers can sign in; before the first one, any Google account can.
112
 
113
+ The panel has two parts, chosen at its top. **כיתות** lists the classes, each with its lessons, students and homework and its buttons: "עריכה", "מחיקה", and "התקדמות התלמידים", which moves to the other part; "כיתה חדשה" opens the steps above. **תלמידים** shows the progress, with a tab per class. A class counts only what its students practised since it was opened (its `created_at`), so a new class starts clean even when its students practised the same lessons before, in another class or in none; the students' own path keeps all they did. A recording that did not match the sentence on the screen (`unrelated`) is no try and has no score, but the panel shows it: "לא זוהו" beside the student, on their screen, and beside each topic it happened in. The tab shows the class's figures, its lessons (each with the sentences the class finds hard: the class average of each student's best score under 70) and its students, with the same columns however many lessons the class has: the homework topic (when one is set), all the class's sentences, and the grade. Before the first class it shows every account that signed in. This replaces the former `ALLOWED_EMAILS`, which is no longer read. The lessons limit what the path shows; the sentences themselves stay public, as before.
114
 
115
  ### Homework
116
 
app/auth.py CHANGED
@@ -198,10 +198,11 @@ class SupabaseAuth:
198
  return users
199
 
200
  def all_attempts(self) -> list[dict]:
201
- """Every student's attempts, as `attempts` gives one student's, each with its user_id."""
 
202
  rows: list[dict] = []
203
  while len(rows) < MAX_CLASS_ATTEMPTS:
204
- query = urllib.parse.urlencode({"select": "user_id,cat,phrase,score,created_at", "unrelated": "is.false",
205
  "order": "id.desc", "limit": PAGE, "offset": len(rows)})
206
  batch = self._admin(f"/rest/v1/attempts?{query}")
207
  rows += batch
 
198
  return users
199
 
200
  def all_attempts(self) -> list[dict]:
201
+ """Every student's attempts, as `attempts` gives one student's, each with its user_id, and with the "different
202
+ sentence" ones too (unrelated = true): the panel shows them beside the progress they are not part of."""
203
  rows: list[dict] = []
204
  while len(rows) < MAX_CLASS_ATTEMPTS:
205
+ query = urllib.parse.urlencode({"select": "user_id,cat,phrase,score,unrelated,created_at",
206
  "order": "id.desc", "limit": PAGE, "offset": len(rows)})
207
  batch = self._admin(f"/rest/v1/attempts?{query}")
208
  rows += batch
app/main.py CHANGED
@@ -89,26 +89,38 @@ def build_version() -> str:
89
  return f"{h.hexdigest()[:7]} · {time.strftime('%Y-%m-%d %H:%M', time.gmtime(newest))} UTC"
90
 
91
 
 
 
 
 
 
92
  def summary(rows: list[dict]) -> dict:
93
  """One student's attempts summed up: best score and tries per phrase id, recordings (all, and of the last
94
- 7 days), days practised, time of the last one."""
 
 
95
  shown = {c["id"] for c in CATEGORIES}
96
  ids = {(p["cat"], p["he"]): i for i, p in enumerate(PHRASES) if p["cat"] in shown}
97
  best: dict[int, int] = {}
98
  tries: dict[int, int] = {}
 
 
99
  days, week, last = set(), 0, None
100
  since = datetime.now(timezone.utc) - timedelta(days=7)
101
  for r in rows:
102
- # the seconds part only: the fraction's length varies, which Python 3.10 does not parse
103
- at = datetime.strptime(r["created_at"][:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
104
  days.add(at.astimezone(LOCAL_TZ).date())
105
  week += at >= since
106
  last = max(last, at) if last else at
107
  i = ids.get((r["cat"], r["phrase"]))
108
- if i is not None: # a sentence the page no longer lists still counts as a recording
 
 
 
 
109
  best[i] = max(best.get(i, 0), r["score"])
110
  tries[i] = tries.get(i, 0) + 1
111
- return {"best": best, "tries": tries, "recordings": len(rows), "days": len(days), "week": week,
112
  "last": last.isoformat() if last else None}
113
 
114
 
@@ -186,7 +198,8 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
186
  @app.get("/api/class")
187
  def class_progress(a: SupabaseAuth = Depends(teacher_only)):
188
  """The teacher's panel: the classes (with their lessons and homework), and every student's summary (see `summary`) with
189
- their classes ({name: nickname}). The students are the classes' accounts, or every account that signed in
 
190
  when there is no class yet; one that never signed in has joined = false."""
191
  try:
192
  classes, users, rows = a.classes(fresh=True), a.users(), a.all_attempts()
@@ -199,10 +212,17 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
199
  for c in classes:
200
  for e in c["emails"]:
201
  member.setdefault(e, {})[c["name"]] = c["nicknames"].get(e, "")
202
- students = [{"email": u["email"], "name": u["name"], "joined": True, "classes": member.get(u["email"].lower(), {}),
203
- **summary(by.get(u["id"], []))} for u in users if not classes or u["email"].lower() in member]
 
 
 
 
 
 
 
204
  missing = member.keys() - {s["email"].lower() for s in students}
205
- students += [{"email": e, "name": "", "joined": False, "classes": member[e], **summary([])} for e in sorted(missing)]
206
  return {"classes": [{**c, "lessons": lessons_of(c), **homework_of(c)} for c in classes], "students": students}
207
 
208
  def class_store_error(e: AuthUnavailable) -> str:
 
89
  return f"{h.hexdigest()[:7]} · {time.strftime('%Y-%m-%d %H:%M', time.gmtime(newest))} UTC"
90
 
91
 
92
+ def utc(stamp: str) -> datetime:
93
+ """A Supabase timestamp. The seconds part only: the fraction's length varies, which Python 3.10 does not parse."""
94
+ return datetime.strptime(stamp[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
95
+
96
+
97
  def summary(rows: list[dict]) -> dict:
98
  """One student's attempts summed up: best score and tries per phrase id, recordings (all, and of the last
99
+ 7 days), days practised, time of the last one. A recording that did not match the sentence on the screen
100
+ (unrelated) is a recording, on its day, but no try and no score: such ones are counted per phrase id in "missed",
101
+ and all together in "unmatched"."""
102
  shown = {c["id"] for c in CATEGORIES}
103
  ids = {(p["cat"], p["he"]): i for i, p in enumerate(PHRASES) if p["cat"] in shown}
104
  best: dict[int, int] = {}
105
  tries: dict[int, int] = {}
106
+ missed: dict[int, int] = {}
107
+ unmatched = 0
108
  days, week, last = set(), 0, None
109
  since = datetime.now(timezone.utc) - timedelta(days=7)
110
  for r in rows:
111
+ at = utc(r["created_at"])
 
112
  days.add(at.astimezone(LOCAL_TZ).date())
113
  week += at >= since
114
  last = max(last, at) if last else at
115
  i = ids.get((r["cat"], r["phrase"]))
116
+ if r.get("unrelated"):
117
+ unmatched += 1
118
+ if i is not None:
119
+ missed[i] = missed.get(i, 0) + 1
120
+ elif i is not None: # a sentence the page no longer lists still counts as a recording
121
  best[i] = max(best.get(i, 0), r["score"])
122
  tries[i] = tries.get(i, 0) + 1
123
+ return {"best": best, "tries": tries, "missed": missed, "unmatched": unmatched, "recordings": len(rows), "days": len(days), "week": week,
124
  "last": last.isoformat() if last else None}
125
 
126
 
 
198
  @app.get("/api/class")
199
  def class_progress(a: SupabaseAuth = Depends(teacher_only)):
200
  """The teacher's panel: the classes (with their lessons and homework), and every student's summary (see `summary`) with
201
+ their classes ({name: nickname}) and, as "since", the same summary for each class ({name: summary}) of the attempts
202
+ made since the class was opened: a new class starts clean, whatever its students practised before. The students are the classes' accounts, or every account that signed in
203
  when there is no class yet; one that never signed in has joined = false."""
204
  try:
205
  classes, users, rows = a.classes(fresh=True), a.users(), a.all_attempts()
 
212
  for c in classes:
213
  for e in c["emails"]:
214
  member.setdefault(e, {})[c["name"]] = c["nicknames"].get(e, "")
215
+ opened = {c["name"]: utc(c["created_at"]) for c in classes if c.get("created_at")}
216
+
217
+ def student(email: str, name: str, joined: bool, mine: list[dict]) -> dict:
218
+ into = member.get(email.lower(), {})
219
+ since = {n: summary([r for r in mine if n not in opened or utc(r["created_at"]) >= opened[n]]) for n in into}
220
+ return {"email": email, "name": name, "joined": joined, "classes": into, "since": since, **summary(mine)}
221
+
222
+ students = [student(u["email"], u["name"], True, by.get(u["id"], []))
223
+ for u in users if not classes or u["email"].lower() in member]
224
  missing = member.keys() - {s["email"].lower() for s in students}
225
+ students += [student(e, "", False, []) for e in sorted(missing)]
226
  return {"classes": [{**c, "lessons": lessons_of(c), **homework_of(c)} for c in classes], "students": students}
227
 
228
  def class_store_error(e: AuthUnavailable) -> str:
static/index.html CHANGED
@@ -202,7 +202,6 @@ body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage
202
  .tab small{font-weight:600;color:var(--muted);margin-inline-start:6px;font-variant-numeric:tabular-nums}
203
  .tab[aria-selected=true]{background:var(--olive);border-color:var(--olive);color:var(--on-olive)}
204
  .tab[aria-selected=true] small{color:inherit;opacity:.8}
205
- .tab.add{border-style:dashed;color:var(--olive);background:transparent}
206
  .chead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
207
  .chead h2{flex:1;margin:0;font:400 24px/1.1 var(--f-display);overflow-wrap:anywhere}
208
  .chead p{flex:0 0 100%;margin:0;color:var(--muted);font-size:14px}
@@ -225,15 +224,14 @@ body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage
225
  .lsum .note{margin:0;padding:6px 16px;color:var(--muted);font-size:14px}
226
  .trow .tries{text-align:end}
227
  /* the students: a row each, a column per lesson (its sentences practised, and a bar), the grade; a row opens on its details */
228
- .rhead,.rrow>summary{display:grid;grid-template-columns:var(--cols);gap:10px;align-items:center;padding:8px 16px}
229
  .rhead{padding-block:0 6px;color:var(--muted);font:700 12px/1.2 var(--f-body)}
230
  .rhead span:not(:first-child){text-align:center}
231
- .rhead span:last-child{text-align:end}
232
- .rrow{border-top:1px solid var(--line)}
233
- .rrow>summary{cursor:pointer;list-style:none}
234
- .rrow>summary::-webkit-details-marker{display:none}
235
- .rrow>summary:hover{background:var(--bg)}
236
- .rrow[open]>summary{background:var(--olive-soft)}
237
  .rrow .who{display:flex;flex-direction:column;min-width:0}
238
  .rrow .who b{align-self:flex-start;max-width:100%;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
239
  .rrow .who small{color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -241,8 +239,19 @@ body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage
241
  .cell .progress{height:5px;margin:0}
242
  .rrow .pill,.rrow .none{justify-self:end}
243
  .rrow .none{color:var(--muted);font-size:12px;text-align:end}
244
- .rdetail{padding:0 0 8px}
245
- .rdetail .stats{margin:0;padding:4px 16px 8px;color:var(--muted);font-size:14px}
 
 
 
 
 
 
 
 
 
 
 
246
  .cform{display:flex;flex-direction:column;gap:10px}
247
  .cform label{display:flex;flex-direction:column;gap:4px;font:700 14px/1.3 var(--f-body)}
248
  .cform label small{font-weight:500;color:var(--muted)}
@@ -266,6 +275,29 @@ body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage
266
  #editLessons .chips label:has(:checked){border-color:var(--olive);background:var(--olive-soft);color:var(--olive)}
267
  #editLessons input{width:18px;height:18px;margin:0;accent-color:var(--olive)}
268
  .btn.danger{color:var(--sumac);border-color:currentColor}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  #logoutBtn{position:fixed;bottom:4px;right:6px;z-index:9;font:500 11px/1 var(--f-body);padding:3px 8px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}
270
  .drive{margin-inline-start:auto;font:700 15px var(--f-body);border-radius:999px;padding:6px 14px;cursor:pointer;border:1px solid var(--olive);background:transparent;color:var(--olive)}
271
  body.driving .drive{background:var(--sumac);border-color:var(--sumac);color:#fff}
@@ -433,10 +465,13 @@ body.driving .card.storymode .prompt{display:block}
433
  <section class="wrap" id="manage" aria-labelledby="mTitle">
434
  <div class="roundbar"><h1 class="mtitle" id="mTitle">ניהול</h1><span class="sep"></span><button class="btn" id="mRefresh">רענון</button><button class="btn primary" id="mPractice">לתרגול</button></div>
435
  <div class="sub" id="mState" aria-live="polite"></div>
 
 
436
  <div class="tabs" id="mTabs" role="tablist" aria-label="כיתות" hidden></div>
437
  <div class="card" id="mHead" hidden></div>
438
  <div class="card hard" id="mLessons" hidden></div>
439
  <div class="card hard roster" id="mRoster" hidden></div>
 
440
  </section>
441
  </div>
442
  <dialog id="listDlg" aria-labelledby="listTitle">
@@ -456,6 +491,34 @@ body.driving .card.storymode .prompt{display:block}
456
  <div class="row"><button class="btn primary" id="editSave"></button><span class="sub" id="editCount"></span><span class="sep"></span><button class="btn danger" type="button" id="editDelete">מחיקת הכיתה</button><span class="msg" id="editMsg" role="alert"></span></div>
457
  </form>
458
  </dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  <dialog id="classDlg" aria-labelledby="classTitle">
460
  <div class="dlg-head"><h2 id="classTitle"></h2><button class="btn" id="classClose">סגירה</button></div>
461
  <div id="mates"></div>
@@ -814,7 +877,7 @@ $("myClass").onclick=async()=>{ // the students of my classes, by the nickname
814
  if(several) note.textContent="";
815
  }catch{ note.textContent="הרשימה לא זמינה כרגע."; }
816
  };
817
- for(const d of document.querySelectorAll("dialog:not(#editDlg,#pickDlg)")) d.onclick=e=>{ // a tap outside the box closes it (not a form being filled)
818
  const r=d.getBoundingClientRect(); if(e.target===d&&(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)) d.close(); };
819
  // Grammar-built phrases (grp set) exist for every pronoun, positive and negative: the two buttons move between them.
820
  function variation(kind){
@@ -1405,7 +1468,7 @@ function showManage(){
1405
  document.body.classList.add("manage"); scrollTo(0,0); loadManage();
1406
  }
1407
  $("toManage").onclick=$("mRefresh").onclick=showManage;
1408
- $("mPractice").onclick=()=>{ document.body.classList.remove("manage"); drawPath(); }; // the path was not laid out while hidden
1409
  const grade=n=>mark("pill "+(n>=EXCELLENT?"good":n>=60?"mid":"bad"),n);
1410
  const mean=a=>a.length?Math.round(a.reduce((x,y)=>x+y,0)/a.length):null;
1411
  function sentence(p,under){ // a sentence as the student sees it, and a small line under it
@@ -1414,11 +1477,15 @@ function sentence(p,under){ // a sentence as the student sees it, and a small
1414
  }
1415
  /* The panel shows one class at a time (a tab each). Before the first class it shows every account that signed in. */
1416
  let CLASS=null, shownClass=store.get("ihki.mclass",""); // the panel's last answer {classes, students}; the class on show
 
 
1417
  let CLASS_LESSONS=[], CLASS_SOON=[]; // the ready lessons of the class on show, and those still to come (every lesson before the first class)
1418
  const HARD=70; // a sentence whose class average (of each student's best score) is under this is flagged as hard
1419
  const listedPhrases=()=>PHRASES.filter(p=>p.listed!==false);
1420
  const lessonPhrases=(l,list)=>{ const cats=ALL_CATS.filter(c=>c.lesson===l.id).map(c=>c.id); return list.filter(p=>cats.includes(p.cat)); };
1421
- const stuTally=(s,list)=>{ const got=list.filter(p=>s.best[p.id]!=null); return {done:got.length, n:list.length, avg:mean(got.map(p=>s.best[p.id])), tries:list.reduce((t,p)=>t+(s.tries[p.id]||0),0)}; };
 
 
1422
  const ratio=t=>{ const m=document.createElement("small"), n=document.createElement("bdi"); n.dir="ltr"; n.textContent=`${t.done}/${t.n}`; m.append(n); return m; };
1423
  const bar=frac=>{ const b=mark("progress",""), f=document.createElement("i"); f.style.width=`${Math.round(frac*100)}%`; b.append(f); return b; };
1424
  const place=p=>{ const c=ALL_CATS.find(c=>c.id===p.cat), l=c&&ALL_LESSONS.find(l=>l.id===c.lesson); return [lessonName(l),c&&c.name].filter(Boolean).join(" · "); };
@@ -1443,35 +1510,82 @@ function drawManage(){
1443
  const mine=ALL_LESSONS.filter(l=>!klass||klass.lessons.includes(l.id));
1444
  CLASS_LESSONS=mine.filter(l=>!l.soon); CLASS_SOON=mine.filter(l=>l.soon);
1445
  const listed=CLASS_LESSONS.flatMap(l=>lessonPhrases(l,listedPhrases())); // the class's sentences: its figures count these only
1446
- // a student of the class, with its nickname in it; the totals beside their own fields (`tries` stays the per-sentence one)
1447
- const students=CLASS.students.filter(s=>!klass||klass.name in s.classes).map(s=>{ const t=stuTally(s,listed);
 
1448
  return {...s,nick:klass?s.classes[klass.name]:Object.values(s.classes).find(Boolean)||"",done:t.done,n:t.n,avg:t.avg}; });
1449
- drawTabs(classes); drawHead(klass,students); drawLessons(students,listed); drawRoster(students,listed);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1450
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1451
  function drawTabs(classes){
1452
  const tabs=$("mTabs"); tabs.innerHTML=""; tabs.hidden=!classes.length;
1453
  for(const c of classes){
1454
  const t=mark("tab","","button"), name=document.createElement("bdi"); t.setAttribute("role","tab");
1455
- name.textContent=c.name; t.append(name); // isolated, so the count does not join a year in the name t.setAttribute("aria-selected",c.name===shownClass);
 
1456
  t.append(mark("",c.emails.length,"small"));
1457
  t.onclick=()=>{ shownClass=c.name; store.set("ihki.mclass",c.name); drawManage(); };
1458
  tabs.append(t);
1459
  }
1460
- const add=mark("tab add","+ כיתה חדשה","button"); add.onclick=()=>editClass(null); tabs.append(add);
1461
  }
1462
- function drawHead(klass,students){ // the class's name, its edit button and its figures; before the first class, how to open one
1463
  const head=$("mHead"); head.innerHTML=""; head.hidden=false;
1464
  const top=mark("chead","");
1465
  if(!klass){
1466
  top.append(mark("","עוד אין כיתות","h2"));
1467
- const open=mark("btn primary","פתיחת כיתה","button"); open.onclick=()=>editClass(null); top.append(open);
1468
  top.append(mark("","עד שנפתחת כיתה, כל חשבון Google יכול להיכנס, ולמטה מופיע כל מי שנכנס. מהכיתה הראשונה נכנסים רק התלמידים שבכיתות והמורים.","p"));
1469
  head.append(top); return;
1470
  }
1471
- const edit=mark("btn","עריכת הכיתה","button"); edit.onclick=()=>editClass(klass);
1472
- const what=byLevel([...CLASS_LESSONS,...CLASS_SOON].sort((a,b)=>ALL_LESSONS.indexOf(a)-ALL_LESSONS.indexOf(b))) // "רמה 1: שיעורים 1–10 (2 מוכנים)"
1473
- .map(g=>`${g.level.name}: ${lessonsWord(g.lessons)} (${g.lessons.filter(l=>!l.soon).length} מוכנים)`).join(" · ");
1474
- top.append(mark("",klass.name,"h2"),edit,mark("",what,"p")); head.append(top);
1475
  const tiles=mark("tiles",""), tile=(n,what)=>{ const t=mark("tile",""), b=mark("",n,"b"); b.dir="ltr"; t.append(b,mark("",what,"small")); tiles.append(t); };
1476
  const avg=mean(students.filter(s=>s.avg!=null).map(s=>s.avg));
1477
  tile(`${students.filter(s=>s.joined).length}/${students.length}`,"נכנסו לאפליקציה");
@@ -1525,51 +1639,78 @@ function drawLessons(students,listed){
1525
  }
1526
  /* The students: the latest to practise first, those who never signed in last. A row opens on the student's numbers, their
1527
  best and weakest sentences, and each lesson by topic. */
1528
- function drawRoster(students,listed){
1529
- // a column per lesson, or per level once the class has more ready lessons than fit
1530
- const cols=CLASS_LESSONS.length<=4?CLASS_LESSONS.map(l=>({name:l.name,title:lessonName(l),list:lessonPhrases(l,listed)}))
1531
- :byLevel(CLASS_LESSONS).map(g=>({name:g.level.name,title:g.level.name,list:g.lessons.flatMap(l=>lessonPhrases(l,listed))}));
1532
- const box=$("mRoster"); box.innerHTML=""; box.hidden=false; box.style.setProperty("--cols",["minmax(0,1fr)",...cols.map(()=>"4.6em"),"3em"].join(" ")); // no lesson column when none is ready
1533
- const h=mark("","תלמידים","h2"); h.append(mark("","לחיצה על תלמיד פותחת את הפירוט","small")); box.append(h);
1534
- if(!students.length){ box.append(mark("note","אין עדיין תלמידים בכיתה הזו. הוסיפו אותם ב\"עריכת הכיתה\".","p")); return; }
1535
- const head=mark("rhead",""); head.append(mark("","תלמיד/ה"),...cols.map(c=>mark("",c.name)),mark("","ציון")); box.append(head);
 
 
1536
  students.sort((a,b)=>(b.last||"").localeCompare(a.last||"")||b.joined-a.joined||a.email.localeCompare(b.email));
1537
  for(const s of students){
1538
- const det=document.createElement("details"), sum=document.createElement("summary"), who=mark("who","");
1539
- det.className="rrow";
1540
- const when=s.last?new Date(s.last).toLocaleString("he-IL",{day:"numeric",month:"numeric",hour:"2-digit",minute:"2-digit"}):"";
1541
  const shown=mark("",s.nick||s.name||s.email,"b"); shown.dir="auto"; // an address is cut at its end
1542
- who.append(shown,mark("",!s.joined?"עוד לא נכנס/ה":when?`לאחרונה ${when}`:"עוד לא תרגל/ה","small"));
1543
- who.title=s.email; sum.append(who);
1544
- for(const col of cols){ const t=stuTally(s,col.list), c=mark("cell",""); c.append(ratio(t),bar(t.n?t.done/t.n:0)); c.title=`${col.title}: תורגלו ${t.done} מתוך ${t.n}`+(t.avg!=null?`, ציון ${t.avg}`:""); sum.append(c); }
1545
- sum.append(s.avg!=null?grade(s.avg):mark("none","–"));
1546
- det.append(sum);
1547
- det.ontoggle=()=>{ if(det.open&&!det.querySelector(".rdetail")) det.append(studentDetail(s,listed)); }; // built when first opened
1548
- box.append(det);
1549
  }
1550
  }
1551
- function studentDetail(s,listed){
1552
- const out=mark("rdetail",""), mail=document.createElement("bdi"); mail.dir="ltr"; mail.textContent=s.email;
1553
- const stats=mark("stats","","p"); stats.append(mail,` · תורגלו ${s.done} מתוך ${s.n} משפטים · ${s.recordings} הקלטות, ${s.week} מהן בשבוע האחרון · ${s.days} ימי תרגול`);
1554
- out.append(stats);
1555
- // the best and weakest sentences, by the best score; a sentence is in one list only
1556
- const got=listed.filter(p=>s.best[p.id]!=null).sort((a,b)=>s.best[b.id]-s.best[a.id]), top=got.slice(0,5), low=got.slice(5).slice(-5).reverse();
1557
- for(const [title,list] of [["5 המשפטים הטובים ביותר",top],["5 המשפטים החלשים ביותר",low]]){
1558
- if(!list.length) continue;
1559
- const sec=part(out); sec.firstChild.append(mark("",title));
1560
- for(const p of list){ const row=mark("trow s3",""), n=s.tries[p.id]||0; row.append(sentence(p,place(p)),mark("",n>1?`${n} ניסיונות`:"","small"),grade(s.best[p.id])); sec.append(row); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1561
  }
1562
- for(const l of CLASS_LESSONS){ // a lesson's topics
1563
- const cats=ALL_CATS.filter(c=>c.lesson===l.id), t=stuTally(s,lessonPhrases(l,listed)), sec=part(out);
1564
- sec.firstChild.append(mark("",`${lessonName(l)} לפי נושאים`),ratio(t),t.avg!=null?grade(t.avg):mark("none","–"));
1565
- for(const c of cats){
 
 
1566
  const t=stuTally(s,listed.filter(p=>p.cat===c.id)), row=mark("trow"+(t.done?"":" untouched"),"");
1567
- row.append(mark("",c.name),mark("",t.tries?`${t.tries} ניסיונות`:"","small"),ratio(t),t.avg!=null?grade(t.avg):mark("none","–")); sec.append(row);
1568
  }
1569
  }
1570
- return out;
 
 
 
 
 
 
 
 
 
 
1571
  }
1572
- /* A class is opened, edited and deleted in one dialog: a name, and a line per student (email, then an optional nickname). */
 
1573
  let editing=null;
1574
  function editClass(c){
1575
  editing=c;
@@ -1627,6 +1768,111 @@ $("editDelete").onclick=()=>{
1627
  if(confirm(`למחוק את הכיתה ${editing.name}? התלמידים שלה לא יוכלו להיכנס עוד. ההקלטות וההתקדמות שלהם נשמרות.`)) sendClass("DELETE");
1628
  };
1629
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1630
  function useLessons(ids){ // the path shows these lessons only (null: every one)
1631
  LESSONS=ALL_LESSONS.filter(l=>!ids||ids.includes(l.id)); if(!LESSONS.length) LESSONS=ALL_LESSONS;
1632
  CATS=ALL_CATS.filter(c=>LESSONS.some(l=>l.id===c.lesson)); // none when every lesson of the student's is still to come
 
202
  .tab small{font-weight:600;color:var(--muted);margin-inline-start:6px;font-variant-numeric:tabular-nums}
203
  .tab[aria-selected=true]{background:var(--olive);border-color:var(--olive);color:var(--on-olive)}
204
  .tab[aria-selected=true] small{color:inherit;opacity:.8}
 
205
  .chead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
206
  .chead h2{flex:1;margin:0;font:400 24px/1.1 var(--f-display);overflow-wrap:anywhere}
207
  .chead p{flex:0 0 100%;margin:0;color:var(--muted);font-size:14px}
 
224
  .lsum .note{margin:0;padding:6px 16px;color:var(--muted);font-size:14px}
225
  .trow .tries{text-align:end}
226
  /* the students: a row each, a column per lesson (its sentences practised, and a bar), the grade; a row opens on its details */
227
+ .rhead,.rrow{display:grid;grid-template-columns:var(--cols);gap:10px;align-items:center;padding:8px 16px}
228
  .rhead{padding-block:0 6px;color:var(--muted);font:700 12px/1.2 var(--f-body)}
229
  .rhead span:not(:first-child){text-align:center}
230
+ .rhead span:nth-last-child(2){text-align:end}
231
+ .rrow{width:100%;border:0;border-top:1px solid var(--line);background:none;color:inherit;font:inherit;text-align:start;cursor:pointer}
232
+ .rrow:hover{background:var(--bg)}
233
+ .rrow .go{color:var(--olive);font:700 20px/1 var(--f-body)}
234
+ .rrow:active{background:var(--olive-soft)}
 
235
  .rrow .who{display:flex;flex-direction:column;min-width:0}
236
  .rrow .who b{align-self:flex-start;max-width:100%;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
237
  .rrow .who small{color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
 
239
  .cell .progress{height:5px;margin:0}
240
  .rrow .pill,.rrow .none{justify-self:end}
241
  .rrow .none{color:var(--muted);font-size:12px;text-align:end}
242
+ /* the panel's two parts: the classes (opened, edited, deleted) and the students (their progress, a class at a time) */
243
+ .modes{display:grid;grid-template-columns:1fr 1fr;gap:4px;padding:4px;border-radius:14px;background:var(--line)}
244
+ .modes button{display:flex;flex-direction:column;align-items:center;gap:2px;padding:8px 6px;border:0;border-radius:10px;background:transparent;color:var(--ink);font:700 16px/1.2 var(--f-body);cursor:pointer}
245
+ .modes button small{font:600 12px/1.2 var(--f-body);color:var(--muted)}
246
+ .modes button[aria-selected=true]{background:var(--surface);box-shadow:0 1px 2px rgba(0,0,0,.15)}
247
+ #manage.classes :is(#mTabs,#mHead,#mLessons,#mRoster,#mStudent),#manage:not(.classes) #mClasses{display:none}
248
+ #mClasses{display:flex;flex-direction:column;gap:inherit}
249
+ #mClasses>.btn{align-self:flex-start}
250
+ #mClasses .card>.btn{align-self:flex-start}
251
+ /* a student's own screen takes the class's place in the panel: the way back, their figures, their lessons, their sentences */
252
+ #manage.student :is(#mTabs,#mHead,#mLessons,#mRoster),#manage:not(.student) #mStudent{display:none}
253
+ #mStudent{display:flex;flex-direction:column;gap:inherit}
254
+ #mStudent .back{align-self:flex-start}
255
  .cform{display:flex;flex-direction:column;gap:10px}
256
  .cform label{display:flex;flex-direction:column;gap:4px;font:700 14px/1.3 var(--f-body)}
257
  .cform label small{font-weight:500;color:var(--muted)}
 
275
  #editLessons .chips label:has(:checked){border-color:var(--olive);background:var(--olive-soft);color:var(--olive)}
276
  #editLessons input{width:18px;height:18px;margin:0;accent-color:var(--olive)}
277
  .btn.danger{color:var(--sumac);border-color:currentColor}
278
+ /* a new class is opened in steps: a line of dots for the way, one question on show, "back" and "next" under it */
279
+ .wiz .dots{display:flex;gap:6px}
280
+ .wiz .dots i{flex:1;height:4px;border-radius:2px;background:var(--line)}
281
+ .wiz .dots i.on{background:var(--olive)}
282
+ .wiz section{display:flex;flex-direction:column;gap:10px;min-height:11em}
283
+ .wiz .hint{margin:0;color:var(--muted);font-size:14px}
284
+ .wiz .opts{display:flex;flex-direction:column;gap:8px}
285
+ .wiz .opts label{flex-direction:row;align-items:center;gap:10px;padding:12px 14px;border-radius:12px;border:1px solid var(--line);background:var(--bg);font:700 17px/1.2 var(--f-body);cursor:pointer}
286
+ .wiz .opts label:has(:checked){border-color:var(--olive);background:var(--olive-soft)}
287
+ .wiz .opts span{display:flex;flex-direction:column;gap:3px}
288
+ .wiz .stus{display:flex;flex-direction:column;max-height:38vh;overflow-y:auto;border:1px solid var(--line);border-radius:10px}
289
+ .wiz .stus:empty{display:none}
290
+ .wiz .stu{display:grid;grid-template-columns:1fr minmax(0,10em);gap:8px;align-items:center;padding:6px 10px}
291
+ .wiz .stu+.stu{border-top:1px solid var(--line)}
292
+ .wiz .stu label{flex-direction:row;align-items:center;gap:10px;min-width:0;cursor:pointer}
293
+ .wiz .stu label span{display:flex;flex-direction:column;min-width:0;overflow-wrap:anywhere}
294
+ .wiz .stu input[type=checkbox],.wiz .opts input{flex:none;width:18px;height:18px;padding:0;accent-color:var(--olive)}
295
+ .wiz .addrow{display:grid;grid-template-columns:1fr auto;gap:8px}
296
+ .wiz .addrow input{direction:ltr;text-align:left;min-width:0}
297
+ .wiz dl,dl.facts{display:grid;grid-template-columns:auto 1fr;gap:6px 14px;margin:0;padding:10px 12px;border-radius:10px;background:var(--bg);font-size:15px}
298
+ .wiz dt,.facts dt{font-weight:700} .wiz dd,.facts dd{margin:0;overflow-wrap:anywhere}
299
+ .wiz .after{margin:0;font-size:14px;line-height:1.5}
300
+ .wiz .after b{display:block}
301
  #logoutBtn{position:fixed;bottom:4px;right:6px;z-index:9;font:500 11px/1 var(--f-body);padding:3px 8px;border-radius:6px;border:1px solid var(--line);background:var(--surface);color:var(--muted);cursor:pointer}
302
  .drive{margin-inline-start:auto;font:700 15px var(--f-body);border-radius:999px;padding:6px 14px;cursor:pointer;border:1px solid var(--olive);background:transparent;color:var(--olive)}
303
  body.driving .drive{background:var(--sumac);border-color:var(--sumac);color:#fff}
 
465
  <section class="wrap" id="manage" aria-labelledby="mTitle">
466
  <div class="roundbar"><h1 class="mtitle" id="mTitle">ניהול</h1><span class="sep"></span><button class="btn" id="mRefresh">רענון</button><button class="btn primary" id="mPractice">לתרגול</button></div>
467
  <div class="sub" id="mState" aria-live="polite"></div>
468
+ <div class="modes" id="mModes" role="tablist" aria-label="ניהול" hidden><button role="tab" data-mode="classes">כיתות<small>פתיחה, עריכה ומחיקה</small></button><button role="tab" data-mode="students">תלמידים<small>ההתקדמות בכל כיתה</small></button></div>
469
+ <div id="mClasses"></div>
470
  <div class="tabs" id="mTabs" role="tablist" aria-label="כיתות" hidden></div>
471
  <div class="card" id="mHead" hidden></div>
472
  <div class="card hard" id="mLessons" hidden></div>
473
  <div class="card hard roster" id="mRoster" hidden></div>
474
+ <div id="mStudent"></div>
475
  </section>
476
  </div>
477
  <dialog id="listDlg" aria-labelledby="listTitle">
 
491
  <div class="row"><button class="btn primary" id="editSave"></button><span class="sub" id="editCount"></span><span class="sep"></span><button class="btn danger" type="button" id="editDelete">מחיקת הכיתה</button><span class="msg" id="editMsg" role="alert"></span></div>
492
  </form>
493
  </dialog>
494
+ <dialog id="newDlg" aria-labelledby="newTitle">
495
+ <div class="dlg-head"><h2 id="newTitle">כיתה חדשה<small id="newStep"></small></h2><button class="btn" id="newClose">סגירה</button></div>
496
+ <form class="cform wiz" id="newForm" novalidate>
497
+ <div class="dots" id="newDots" aria-hidden="true"></div>
498
+ <section>
499
+ <label>איך קוראים לכיתה?<input id="newName" maxlength="80" placeholder="למשל: מתחילים אוקטובר 2026" autocomplete="off"></label>
500
+ <p class="hint">השם מופיע אצלכם בפאנל ואצל התלמידים בראש המסלול.</p>
501
+ </section>
502
+ <section>
503
+ <fieldset class="lpick"><legend>באיזו רמה הכיתה לומדת?<small>התלמידים יראו במסלול את השיעורים של הרמה הזו. שיעור שעוד לא מוכן ייפתח להם לבד כשיהיה.</small></legend><div class="opts" id="newLevels"></div></fieldset>
504
+ </section>
505
+ <section>
506
+ <fieldset class="lpick"><legend>מי בכיתה?<small>סמנו את התלמידים. ליד כל תלמיד שסימנתם אפשר לכתוב כינוי: כך יקראו לו בכיתה, במקום השם שלו ב-Google.</small></legend><div class="stus" id="newStudents"></div></fieldset>
507
+ <label>תלמיד שלא ברשימה?<small>כתבו את כתובת ה-Gmail שלו ולחצו „הוספה”.</small>
508
+ <span class="addrow"><input id="newEmail" type="email" placeholder="dana@gmail.com" autocomplete="off" spellcheck="false"><button class="btn" type="button" id="newAdd">הוספה</button></span></label>
509
+ <span class="sub" id="newCount"></span>
510
+ </section>
511
+ <section>
512
+ <label>מה שיעורי הבית של הכיתה?<small>נושא אחד שהכיתה מתרגלת עכשיו, למשל שיחה. התלמידים רואים אותו מסומן במסלול, ואתם רואים בפאנל מי סיים. אפשר גם לדלג ולבחור אחר כך.</small><select id="newHw"></select></label>
513
+ <label id="newDueBox">עד איזה יום?<small>לא חובה.</small><input type="date" id="newDue"></label>
514
+ </section>
515
+ <section>
516
+ <dl id="newSummary"></dl>
517
+ <p class="after" id="newAfter"></p>
518
+ </section>
519
+ <div class="row"><button class="btn" type="button" id="newBack">חזרה</button><span class="sep"></span><button class="btn primary" id="newNext"></button><span class="msg" id="newMsg" role="alert"></span></div>
520
+ </form>
521
+ </dialog>
522
  <dialog id="classDlg" aria-labelledby="classTitle">
523
  <div class="dlg-head"><h2 id="classTitle"></h2><button class="btn" id="classClose">סגירה</button></div>
524
  <div id="mates"></div>
 
877
  if(several) note.textContent="";
878
  }catch{ note.textContent="הרשימה לא זמינה כרגע."; }
879
  };
880
+ for(const d of document.querySelectorAll("dialog:not(#editDlg,#newDlg,#pickDlg)")) d.onclick=e=>{ // a tap outside the box closes it (not a form being filled)
881
  const r=d.getBoundingClientRect(); if(e.target===d&&(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)) d.close(); };
882
  // Grammar-built phrases (grp set) exist for every pronoun, positive and negative: the two buttons move between them.
883
  function variation(kind){
 
1468
  document.body.classList.add("manage"); scrollTo(0,0); loadManage();
1469
  }
1470
  $("toManage").onclick=$("mRefresh").onclick=showManage;
1471
+ $("mPractice").onclick=()=>{ document.body.classList.remove("manage"); shownStudent=""; drawPath(); }; // the path was not laid out while hidden
1472
  const grade=n=>mark("pill "+(n>=EXCELLENT?"good":n>=60?"mid":"bad"),n);
1473
  const mean=a=>a.length?Math.round(a.reduce((x,y)=>x+y,0)/a.length):null;
1474
  function sentence(p,under){ // a sentence as the student sees it, and a small line under it
 
1477
  }
1478
  /* The panel shows one class at a time (a tab each). Before the first class it shows every account that signed in. */
1479
  let CLASS=null, shownClass=store.get("ihki.mclass",""); // the panel's last answer {classes, students}; the class on show
1480
+ let mMode=store.get("ihki.mmode",""); // the panel's part on show: "classes" or "students" (the classes, before the first one)
1481
+ let shownStudent="", classScroll=0; // the student whose screen is on show (an email; "" for the class's), and where the class's was left
1482
  let CLASS_LESSONS=[], CLASS_SOON=[]; // the ready lessons of the class on show, and those still to come (every lesson before the first class)
1483
  const HARD=70; // a sentence whose class average (of each student's best score) is under this is flagged as hard
1484
  const listedPhrases=()=>PHRASES.filter(p=>p.listed!==false);
1485
  const lessonPhrases=(l,list)=>{ const cats=ALL_CATS.filter(c=>c.lesson===l.id).map(c=>c.id); return list.filter(p=>cats.includes(p.cat)); };
1486
+ const stuTally=(s,list)=>{ const got=list.filter(p=>s.best[p.id]!=null), sum=k=>list.reduce((t,p)=>t+(s[k]&&s[k][p.id]||0),0);
1487
+ return {done:got.length, n:list.length, avg:mean(got.map(p=>s.best[p.id])), tries:sum("tries"), missed:sum("missed")}; }; // missed: recordings that did not match their sentence
1488
+ const triesText=t=>[t.tries&&`${t.tries} ניסיונות`,t.missed&&`${t.missed} לא זוהו`].filter(Boolean).join(" · ");
1489
  const ratio=t=>{ const m=document.createElement("small"), n=document.createElement("bdi"); n.dir="ltr"; n.textContent=`${t.done}/${t.n}`; m.append(n); return m; };
1490
  const bar=frac=>{ const b=mark("progress",""), f=document.createElement("i"); f.style.width=`${Math.round(frac*100)}%`; b.append(f); return b; };
1491
  const place=p=>{ const c=ALL_CATS.find(c=>c.id===p.cat), l=c&&ALL_LESSONS.find(l=>l.id===c.lesson); return [lessonName(l),c&&c.name].filter(Boolean).join(" · "); };
 
1510
  const mine=ALL_LESSONS.filter(l=>!klass||klass.lessons.includes(l.id));
1511
  CLASS_LESSONS=mine.filter(l=>!l.soon); CLASS_SOON=mine.filter(l=>l.soon);
1512
  const listed=CLASS_LESSONS.flatMap(l=>lessonPhrases(l,listedPhrases())); // the class's sentences: its figures count these only
1513
+ // a student of the class, with its nickname in it and what they practised since it was opened (a new class starts
1514
+ // clean); the totals beside their own fields (`tries` stays the per-sentence one)
1515
+ const students=CLASS.students.filter(s=>!klass||klass.name in s.classes).map(s=>{ if(klass&&s.since) s={...s,...s.since[klass.name]}; const t=stuTally(s,listed);
1516
  return {...s,nick:klass?s.classes[klass.name]:Object.values(s.classes).find(Boolean)||"",done:t.done,n:t.n,avg:t.avg}; });
1517
+ if(mMode!=="classes"&&mMode!=="students") mMode=classes.length?"students":"classes";
1518
+ $("mModes").hidden=false; $("manage").classList.toggle("classes",mMode==="classes");
1519
+ for(const b of $("mModes").children) b.setAttribute("aria-selected",b.dataset.mode===mMode);
1520
+ drawClasses(classes); drawTabs(classes); drawHead(klass,students); drawLessons(students,listed); drawRoster(klass,students,listed);
1521
+ const one=students.find(s=>s.email===shownStudent); // a student who left the class has no screen in it
1522
+ if(!one) shownStudent="";
1523
+ $("manage").classList.toggle("student",!!one); $("mStudent").innerHTML="";
1524
+ if(one) drawStudent(klass,one,listed);
1525
+ }
1526
+ function showMode(mode){ mMode=mode; store.set("ihki.mmode",mode); drawManage(); }
1527
+ for(const b of $("mModes").children) b.onclick=()=>showMode(b.dataset.mode);
1528
+ const lessonsLine=ls=>byLevel(ls).map(g=>`${g.level.name}: ${lessonsWord(g.lessons)} (${g.lessons.filter(l=>!l.soon).length} מוכנים)`).join(" · "); // "רמה 1: שיעורים 1–10 (2 מוכנים)"
1529
+ /* The classes' part: a card per class with what it is made of, and its buttons: edit, delete, and on to its students' progress. */
1530
+ function drawClasses(classes){
1531
+ const box=$("mClasses"); box.innerHTML="";
1532
+ if(!classes.length){
1533
+ const none=mark("card",""), top=mark("chead","");
1534
+ top.append(mark("","עוד אין כיתות","h2"),mark("","עד שנפתחת כיתה, כל חשבון Google יכול להיכנס, ותחת „תלמידים” מופיע כל מי שנכנס. מהכיתה הראשונה נכנסים רק התלמידים שבכיתות והמורים.","p"));
1535
+ none.append(top); box.append(none);
1536
+ }
1537
+ const add=mark("btn primary","+ כיתה חדשה","button"); add.onclick=newClass; box.append(add);
1538
+ for(const c of classes){
1539
+ const card=mark("card",""), top=mark("chead",""), name=mark("","","h2"), n=document.createElement("bdi"), edit=mark("btn","עריכה","button"), del=mark("btn danger","מחיקה","button");
1540
+ n.textContent=c.name; name.append(n); edit.onclick=()=>editClass(c); del.onclick=()=>deleteClass(c); top.append(name,edit,del);
1541
+ const facts=mark("facts","","dl"), fact=(what,...value)=>{ const dd=document.createElement("dd"); dd.append(...value); facts.append(mark("",what,"dt"),dd); };
1542
+ fact("שיעורים",lessonsLine(ALL_LESSONS.filter(l=>c.lessons.includes(l.id))));
1543
+ const mine=CLASS.students.filter(s=>c.name in s.classes);
1544
+ fact("תלמידים",...(mine.length?[`${mine.length}: `,...mine.flatMap((s,k)=>{ const b=document.createElement("bdi"); b.textContent=s.classes[c.name]||s.name||s.email; return k?[", ",b]:[b]; })]:["עוד אין"]));
1545
+ const hw=c.homework&&ALL_CATS.find(t=>t.id===c.homework);
1546
+ fact("שיעורי בית",hw?[hw.name,lessonName(ALL_LESSONS.find(l=>l.id===hw.lesson)),dueText(c.due).text].filter(Boolean).join(" · "):"אין");
1547
+ const go=mark("btn","התקדמות התלמידים ‹","button"); go.onclick=()=>{ shownClass=c.name; store.set("ihki.mclass",c.name); shownStudent=""; showMode("students"); scrollTo(0,0); };
1548
+ card.append(top,facts,go); box.append(card);
1549
+ }
1550
  }
1551
+ async function deleteClass(c){
1552
+ if(!confirm(`למחוק את הכיתה ${c.name}? התלמידים שלה לא יוכלו להיכנס עוד. ההקלטות וההתקדמות שלהם נשמרות.`)) return;
1553
+ try{
1554
+ const res=await api(`/api/classes/${c.id}`,{method:"DELETE"});
1555
+ if(!res.ok) alert((await res.json().catch(()=>({}))).detail||`המחיקה נכשלה (${res.status})`);
1556
+ }catch{ alert("השרת לא זמין."); }
1557
+ loadManage();
1558
+ }
1559
+ /* A student's screen is a step in the browser's history, so its back button returns to the class too. */
1560
+ function showStudent(email){
1561
+ classScroll=scrollY; shownStudent=email; history.pushState({student:email},""); drawManage(); scrollTo(0,0);
1562
+ }
1563
+ function leaveStudent(){ if(history.state&&history.state.student) history.back(); else{ shownStudent=""; drawManage(); scrollTo(0,classScroll); } }
1564
+ addEventListener("popstate",e=>{
1565
+ const to=e.state&&e.state.student||""; if(to===shownStudent||!CLASS) return;
1566
+ shownStudent=to; drawManage(); scrollTo(0,to?0:classScroll);
1567
+ });
1568
  function drawTabs(classes){
1569
  const tabs=$("mTabs"); tabs.innerHTML=""; tabs.hidden=!classes.length;
1570
  for(const c of classes){
1571
  const t=mark("tab","","button"), name=document.createElement("bdi"); t.setAttribute("role","tab");
1572
+ name.textContent=c.name; t.append(name); // isolated, so the count does not join a year in the name
1573
+ t.setAttribute("aria-selected",c.name===shownClass);
1574
  t.append(mark("",c.emails.length,"small"));
1575
  t.onclick=()=>{ shownClass=c.name; store.set("ihki.mclass",c.name); drawManage(); };
1576
  tabs.append(t);
1577
  }
 
1578
  }
1579
+ function drawHead(klass,students){ // the class's name and its figures; before the first class, where to open one
1580
  const head=$("mHead"); head.innerHTML=""; head.hidden=false;
1581
  const top=mark("chead","");
1582
  if(!klass){
1583
  top.append(mark("","עוד אין כיתות","h2"));
1584
+ const open=mark("btn primary","פתיחת כיתה","button"); open.onclick=newClass; top.append(open);
1585
  top.append(mark("","עד שנפתחת כיתה, כל חשבון Google יכול להיכנס, ולמטה מופיע כל מי שנכנס. מהכיתה הראשונה נכנסים רק התלמידים שבכיתות והמורים.","p"));
1586
  head.append(top); return;
1587
  }
1588
+ top.append(mark("",klass.name,"h2"),mark("",lessonsLine(ALL_LESSONS.filter(l=>klass.lessons.includes(l.id))),"p")); head.append(top);
 
 
 
1589
  const tiles=mark("tiles",""), tile=(n,what)=>{ const t=mark("tile",""), b=mark("",n,"b"); b.dir="ltr"; t.append(b,mark("",what,"small")); tiles.append(t); };
1590
  const avg=mean(students.filter(s=>s.avg!=null).map(s=>s.avg));
1591
  tile(`${students.filter(s=>s.joined).length}/${students.length}`,"נכנסו לאפליקציה");
 
1639
  }
1640
  /* The students: the latest to practise first, those who never signed in last. A row opens on the student's numbers, their
1641
  best and weakest sentences, and each lesson by topic. */
1642
+ function drawRoster(klass,students,listed){
1643
+ // the same columns however many lessons the class has: the homework topic (when there is one) and all the class's
1644
+ // sentences; a lesson by itself is on the student's own screen
1645
+ const hw=klass&&klass.homework&&ALL_CATS.find(c=>c.id===klass.homework&&CLASS_LESSONS.some(l=>l.id===c.lesson));
1646
+ const cols=[...(hw?[{name:"שיעורי בית",title:`שיעורי בית, ${hw.name}`,list:listed.filter(p=>p.cat===hw.id)}]:[]),
1647
+ ...(CLASS_LESSONS.length?[{name:"כל השיעורים",title:"כל השיעורים",list:listed}]:[])];
1648
+ const box=$("mRoster"); box.innerHTML=""; box.hidden=false; box.style.setProperty("--cols",["minmax(0,1fr)",...cols.map(()=>"4.2em"),"3em","10px"].join(" ")); // no lesson column when none is ready
1649
+ const h=mark("","תלמידים","h2"); h.append(mark("","לחיצה על תלמיד פותחת את הדף שלו","small")); box.append(h);
1650
+ if(!students.length){ box.append(mark("note","אין עדיין תלמידים בכיתה הזו. מוסיפים אותם תחת „כיתות”, בכפתור „עריכה” של הכיתה.","p")); return; }
1651
+ const head=mark("rhead",""); head.append(mark("","תלמיד/ה"),...cols.map(c=>mark("",c.name)),mark("","ציון"),mark("","")); box.append(head);
1652
  students.sort((a,b)=>(b.last||"").localeCompare(a.last||"")||b.joined-a.joined||a.email.localeCompare(b.email));
1653
  for(const s of students){
1654
+ const row=mark("rrow","","button"), who=mark("who","");
 
 
1655
  const shown=mark("",s.nick||s.name||s.email,"b"); shown.dir="auto"; // an address is cut at its end
1656
+ who.append(shown,mark("",lastSeen(s)+(s.unmatched?` · ${s.unmatched} לא זוהו`:""),"small"));
1657
+ who.title=s.email; row.append(who);
1658
+ for(const col of cols){ const t=stuTally(s,col.list), c=mark("cell",""); c.append(ratio(t),bar(t.n?t.done/t.n:0)); c.title=`${col.title}: תורגלו ${t.done} מתוך ${t.n}`+(t.avg!=null?`, ציון ${t.avg}`:""); row.append(c); }
1659
+ row.append(s.avg!=null?grade(s.avg):mark("none","–"),mark("go","‹")); // the arrow says the row opens
1660
+ row.onclick=()=>showStudent(s.email);
1661
+ box.append(row);
 
1662
  }
1663
  }
1664
+ function lastSeen(s){
1665
+ const when=s.last?new Date(s.last).toLocaleString("he-IL",{day:"numeric",month:"numeric",hour:"2-digit",minute:"2-digit"}):"";
1666
+ return !s.joined?"עוד לא נכנס/ה":when?`לאחרונה ${when}`:"עוד לא תרגל/ה";
1667
+ }
1668
+ /* A student's screen, for the class on show (so it counts what the class's does): who they are and their figures, the
1669
+ homework, every lesson of the class with its topics, and their best and weakest sentences. */
1670
+ function drawStudent(klass,s,listed){
1671
+ const box=$("mStudent"), back=mark("btn back",klass?"→ חזרה לכיתה":"→ חזרה לרשימה","button"); back.onclick=leaveStudent; box.append(back);
1672
+ const head=mark("card",""), top=mark("chead",""), name=mark("",s.nick||s.name||s.email,"h2"), who=mark("","","p"), mail=document.createElement("bdi");
1673
+ name.dir="auto"; mail.dir="ltr"; mail.textContent=s.email;
1674
+ if(s.nick&&s.name){ const g=document.createElement("bdi"); g.textContent=s.name; who.append(g," · "); }
1675
+ if(s.nick||s.name) who.append(mail," · ");
1676
+ if(klass){ const c=document.createElement("bdi"); c.textContent=klass.name; who.append("כיתת ",c," · "); }
1677
+ who.append(lastSeen(s)); top.append(name,who); head.append(top);
1678
+ const tiles=mark("tiles",""), tile=(n,what)=>{ const t=mark("tile",""), b=mark("",n,"b"); b.dir="ltr"; t.append(b,mark("",what,"small")); tiles.append(t); };
1679
+ tile(`${s.done}/${s.n}`,"משפטים תורגלו"); tile(s.week,"הקלטות בשבוע האחרון"); tile(s.days,"ימי תרגול"); tile(s.avg??"–","ציון ממוצע");
1680
+ head.append(tiles);
1681
+ if(s.unmatched) head.append(mark("note",`${s.unmatched} הקלטות לא זוהו כמשפט שעל המסך: הן נשמרו, אבל לא נספרות במשפטים שתורגלו ולא בציון. ליד כל נושא מופיע כמה כאלה היו בו.`,"p"));
1682
+ const hw=klass&&klass.homework&&ALL_CATS.find(c=>c.id===klass.homework&&CLASS_LESSONS.some(l=>l.id===c.lesson));
1683
+ if(hw){
1684
+ const t=stuTally(s,listed.filter(p=>p.cat===hw.id)), due=dueText(klass.due), over=t.done>=t.n;
1685
+ const sum=mark("hwsum"+(due.late&&!over?" late":""),""), line=mark("",`שיעורי בית: ${hw.name}`,"b");
1686
+ line.append(mark("",` · ${lessonName(ALL_LESSONS.find(l=>l.id===hw.lesson))}`,"small"));
1687
+ if(due.text) line.append(mark(""," · "+due.text,"small"));
1688
+ sum.append(line,mark("",over?`✓ סיים/ה: כל ${t.n} המשפטים תורגלו`:`תורגלו ${t.done} מתוך ${t.n} מ��פטים`,"small")); head.append(sum);
1689
  }
1690
+ box.append(head);
1691
+ const lessons=mark("card hard",""), lh=mark("","שיעורים","h2"); lh.append(mark("","לחיצה על שיעור פותחת את הנושאים שלו","small")); lessons.append(lh);
1692
+ for(const l of CLASS_LESSONS){
1693
+ const t=stuTally(s,lessonPhrases(l,listed)), sec=part(lessons); sec.classList.add("lsum");
1694
+ sec.firstChild.append(mark("",lessonName(l)),mark("",t.done?`תורגלו ${t.done} מתוך ${t.n} משפטים`:t.missed?`ניסה/תה, לא זוהה (${t.missed})`:"עוד לא התחיל/ה","small"),t.avg!=null?grade(t.avg):mark("none","–"),bar(t.n?t.done/t.n:0));
1695
+ for(const c of ALL_CATS.filter(c=>c.lesson===l.id)){
1696
  const t=stuTally(s,listed.filter(p=>p.cat===c.id)), row=mark("trow"+(t.done?"":" untouched"),"");
1697
+ row.append(mark("",c.name),mark("",triesText(t),"small"),ratio(t),t.avg!=null?grade(t.avg):mark("none","–")); sec.append(row);
1698
  }
1699
  }
1700
+ if(CLASS_SOON.length) lessons.append(mark("note","בקרוב: "+byLevel(CLASS_SOON).map(g=>`${g.level.name}, ${lessonsWord(g.lessons)}`).join(" · "),"p"));
1701
+ box.append(lessons);
1702
+ // the best and weakest sentences, by the best score; a sentence is in one list only
1703
+ const got=listed.filter(p=>s.best[p.id]!=null).sort((a,b)=>s.best[b.id]-s.best[a.id]), top5=got.slice(0,5), low=got.slice(5).slice(-5).reverse();
1704
+ const marks=mark("card hard",""); marks.append(mark("","משפטים","h2")); box.append(marks);
1705
+ if(!got.length) marks.append(mark("note",klass?"עוד לא תרגל/ה משפטים מאז שהכיתה נפתחה.":"עוד לא תרגל/ה משפטים.","p"));
1706
+ for(const [title,list] of [["המשפטים הטובים ביותר",top5],["המשפטים החלשים ביותר",low]]){
1707
+ if(!list.length) continue;
1708
+ const sec=part(marks); sec.open=true; sec.firstChild.append(mark("",title));
1709
+ for(const p of list){ const row=mark("trow s3",""), n=s.tries[p.id]||0; row.append(sentence(p,place(p)),mark("",n>1?`${n} ניסיונות`:"","small"),grade(s.best[p.id])); sec.append(row); }
1710
+ }
1711
  }
1712
+ /* A class is edited and deleted in one dialog: a name, its lessons, its homework, and a line per student (email, then an
1713
+ optional nickname). A new class is opened in the steps below it. */
1714
  let editing=null;
1715
  function editClass(c){
1716
  editing=c;
 
1768
  if(confirm(`למחוק את הכיתה ${editing.name}? התלמידים שלה לא יוכלו להיכנס עוד. ההקלטות וההתקדמות שלהם נשמרות.`)) sendClass("DELETE");
1769
  };
1770
 
1771
+ /* A new class is opened in steps, a question each: its name, its level (the class studies the level's lessons), its
1772
+ students with their nicknames, its homework, and a summary to confirm, which says where all of it is changed later. */
1773
+ const WIZ=["שם הכיתה","רמה","תלמידים","שיעורי בית","אישור"], ADDRESS=/[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+/g;
1774
+ let wiz=null; // {step, students: [{email, name, on, nick}]}: the accounts the panel knows, and those added here
1775
+ const wizName=()=>$("newName").value.trim().replace(/\s+/g," ");
1776
+ const wizLevel=()=>ALL_LEVELS.find(v=>v.id===($("newLevels").querySelector(":checked")||{}).value)||ALL_LEVELS[0];
1777
+ const wizLessons=()=>ALL_LESSONS.filter(l=>l.level===wizLevel().id);
1778
+ const wizWho=s=>s.nick.trim()||s.name||s.email;
1779
+ function newClass(){
1780
+ wiz={step:0,students:CLASS.students.map(s=>({email:s.email.toLowerCase(),name:s.name,on:false,nick:""}))};
1781
+ $("newName").value=$("newEmail").value=$("newDue").value=""; $("newHw").innerHTML="";
1782
+ const levels=$("newLevels"); levels.innerHTML="";
1783
+ ALL_LEVELS.forEach((v,k)=>{
1784
+ const lab=document.createElement("label"), box=document.createElement("input"), text=document.createElement("span"), ls=ALL_LESSONS.filter(l=>l.level===v.id);
1785
+ box.type="radio"; box.name="newLevel"; box.value=v.id; box.checked=!k;
1786
+ text.append(v.name,mark("",`${lessonsWord(ls)} · ${ls.filter(l=>!l.soon).length} מוכנים`,"small")); lab.append(box,text); levels.append(lab);
1787
+ });
1788
+ wizStudents(); wizStep(0); $("newDlg").showModal(); $("newName").focus();
1789
+ }
1790
+ function wizStep(n){
1791
+ wiz.step=n; $("newMsg").textContent="";
1792
+ [...$("newForm").querySelectorAll("section")].forEach((s,k)=>s.hidden=k!==n);
1793
+ $("newStep").textContent=`שלב ${n+1} מתוך ${WIZ.length}: ${WIZ[n]}`;
1794
+ $("newDots").replaceChildren(...WIZ.map((_,k)=>mark(k<=n?"on":"","","i")));
1795
+ $("newBack").hidden=!n; $("newNext").textContent=n===WIZ.length-1?"פתיחת הכיתה":"המשך";
1796
+ if(n===3) wizHomework();
1797
+ if(n===4) wizSummary();
1798
+ }
1799
+ function wizStudents(){ // a row per student: a box to tick, and once ticked a field for the nickname
1800
+ const list=$("newStudents"), count=()=>{ const n=wiz.students.filter(s=>s.on).length; $("newCount").textContent=n?`נבחרו ${n} תלמידים`:"עוד לא נבחרו תלמידים"; };
1801
+ list.innerHTML="";
1802
+ for(const s of wiz.students){
1803
+ const row=mark("stu",""), lab=document.createElement("label"), box=document.createElement("input"), who=document.createElement("span"), nick=document.createElement("input");
1804
+ const mail=mark("",s.email,s.name?"small":"bdi"); mail.dir="ltr"; mail.style.textAlign="start";
1805
+ box.type="checkbox"; box.checked=s.on; if(s.name) who.append(s.name); who.append(mail); lab.append(box,who);
1806
+ nick.placeholder="כינוי (לא חובה)"; nick.maxLength=40; nick.value=s.nick; nick.hidden=!s.on; nick.setAttribute("aria-label",`כינוי של ${s.name||s.email}`);
1807
+ box.onchange=()=>{ s.on=box.checked; nick.hidden=!s.on; count(); };
1808
+ nick.oninput=()=>s.nick=nick.value;
1809
+ nick.onkeydown=e=>{ if(e.key==="Enter") e.preventDefault(); }; // Enter in a nickname does not leave the step
1810
+ row.append(lab,nick); list.append(row);
1811
+ }
1812
+ count();
1813
+ }
1814
+ function wizAdd(){ // the addresses written in the field join the list, ticked; false when there is text but no address
1815
+ const box=$("newEmail"), text=box.value.trim(), found=text.match(ADDRESS)||[];
1816
+ if(text&&!found.length){ $("newMsg").textContent="זו לא כתובת מייל מלאה. כתבו למשל dana@gmail.com"; box.focus(); return false; }
1817
+ for(const e of found.map(e=>e.toLowerCase())){
1818
+ const s=wiz.students.find(s=>s.email===e);
1819
+ if(s) s.on=true; else wiz.students.push({email:e,name:"",on:true,nick:""});
1820
+ }
1821
+ if(found.length){ box.value=""; $("newMsg").textContent=""; wizStudents(); $("newStudents").scrollTop=$("newStudents").scrollHeight; }
1822
+ return true;
1823
+ }
1824
+ $("newAdd").onclick=()=>{ wizAdd(); $("newEmail").focus(); };
1825
+ $("newEmail").onkeydown=e=>{ if(e.key==="Enter"){ e.preventDefault(); wizAdd(); } };
1826
+ function wizHomework(){ // the topics of the level's ready lessons, under their lessons; the one chosen before stays
1827
+ const hw=$("newHw"), was=hw.value, ready=wizLessons().filter(l=>!l.soon);
1828
+ hw.innerHTML=""; hw.add(new Option(ready.length?"בלי שיעורי בית בינתיים":"לרמה הזו עוד אין שיעורים מוכנים",""));
1829
+ for(const l of ready){
1830
+ const g=document.createElement("optgroup"); g.label=l.name; hw.append(g);
1831
+ for(const t of ALL_CATS.filter(t=>t.lesson===l.id)) g.append(new Option(t.name,t.id));
1832
+ }
1833
+ hw.value=[...hw.options].some(o=>o.value===was)?was:""; hw.disabled=!ready.length;
1834
+ $("newDue").min=new Date().toLocaleDateString("sv"); hw.onchange();
1835
+ }
1836
+ $("newHw").onchange=()=>$("newDueBox").hidden=!$("newHw").value;
1837
+ function wizSummary(){
1838
+ const sum=$("newSummary"), level=wizLevel(), picked=wiz.students.filter(s=>s.on), topic=ALL_CATS.find(c=>c.id===$("newHw").value);
1839
+ const line=(what,...value)=>{ const dd=document.createElement("dd"); dd.append(...value); sum.append(mark("",what,"dt"),dd); };
1840
+ sum.innerHTML="";
1841
+ const name=document.createElement("bdi"); name.textContent=wizName(); line("שם",name);
1842
+ line("רמה",`${level.name}, ${lessonsWord(wizLessons())}`);
1843
+ line("תלמידים",...(picked.length?[`${picked.length}: `,...picked.flatMap((s,k)=>{ const b=document.createElement("bdi"); b.textContent=wizWho(s); return k?[", ",b]:[b]; })]:["עוד אין. אפשר להוסיף אחר כך."]));
1844
+ line("שיעורי בית",topic?[topic.name,ALL_LESSONS.find(l=>l.id===topic.lesson).name,dueText($("newDue").value).text].filter(Boolean).join(" · "):"אין בינתיים");
1845
+ const after=$("newAfter"); after.innerHTML="";
1846
+ after.append(mark("","מה קורה אחרי הפתיחה?","b"),"הכיתה מופיעה ברשימה תחת „כיתות”, והתלמידים שלה רואים את השיעורים במסלול. את ההתקדמות שלהם רואים תחת „תלמידים”. שום דבר כאן לא סופי: ליד כל כיתה ברשימה יש כפתור „עריכה”, שבו משנים את השם, השיעורים, התלמידים והכינויים ושיעורי הבית, וכפתור „מחיקה”.");
1847
+ if(!CLASS.classes.length) after.append(mark("","שימו לב: זו הכיתה הראשונה. מרגע שהיא נפתחת, נכנסים לאפליקציה רק התלמידים שבכיתות והמורים.","b"));
1848
+ }
1849
+ $("newBack").onclick=()=>wizStep(wiz.step-1);
1850
+ $("newClose").onclick=()=>$("newDlg").close();
1851
+ $("newForm").onsubmit=async e=>{ // "next" checks the step on show; on the last one the class is opened
1852
+ e.preventDefault();
1853
+ const msg=$("newMsg"), n=wiz.step, name=wizName();
1854
+ if(n===0){
1855
+ const taken=CLASS.classes.find(c=>c.name.replace(/\s+/g," ").trim().toLowerCase()===name.toLowerCase());
1856
+ if(!name||taken){ msg.textContent=name?`כבר יש כיתה בשם ${taken.name}. בחרו שם אחר.`:"כתבו שם לכיתה."; $("newName").focus(); return; }
1857
+ }
1858
+ if(n===2&&!wizAdd()) return; // an address still in the field joins too
1859
+ if(n<WIZ.length-1){ wizStep(n+1); return; }
1860
+ const homework=$("newHw").value, buttons=$("newForm").querySelectorAll("button");
1861
+ const emails=wiz.students.filter(s=>s.on).map(s=>`${s.email} ${s.nick.replace(/[\s@]+/g," ").trim()}`.trim()).join("\n");
1862
+ msg.textContent=""; buttons.forEach(b=>b.disabled=true);
1863
+ try{
1864
+ const res=await api("/api/classes",{method:"POST",headers:{"Content-Type":"application/json"},
1865
+ body:JSON.stringify({name,emails,lessons:wizLessons().map(l=>l.id),homework,due:homework?$("newDue").value:""})});
1866
+ if(res.ok){ shownClass=name; store.set("ihki.mclass",name); $("newDlg").close(); loadManage(); }
1867
+ else{
1868
+ const detail=(await res.json().catch(()=>({}))).detail||`הפתיחה נכשלה (${res.status})`;
1869
+ if(res.status===409) wizStep(0); // the name was taken meanwhile: back to it
1870
+ msg.textContent=detail;
1871
+ }
1872
+ }catch{ msg.textContent="השרת לא זמין."; }
1873
+ buttons.forEach(b=>b.disabled=false);
1874
+ };
1875
+
1876
  function useLessons(ids){ // the path shows these lessons only (null: every one)
1877
  LESSONS=ALL_LESSONS.filter(l=>!ids||ids.includes(l.id)); if(!LESSONS.length) LESSONS=ALL_LESSONS;
1878
  CATS=ALL_CATS.filter(c=>LESSONS.some(l=>l.id===c.lesson)); // none when every lesson of the student's is still to come
tests/test_scoring.py CHANGED
@@ -544,6 +544,23 @@ def test_sign_in_guards_the_practice_endpoints():
544
  assert (new["email"], new["joined"], new["recordings"]) == ("new@b.c", False, 0) and student["joined"]
545
  assert (student["classes"], new["classes"]) == ({"October 2026": ""}, {"October 2026": "דנה"})
546
  assert x["classes"] == {"October 2026": "", "September 2026": ""} and y["classes"] == {"September 2026": ""}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547
  assert c.delete("/api/classes/1", headers=good).status_code == 200 and len(c.app.state.auth.cls) == 1
548
  # a student sees each of their classes: by nickname, else the Google name; one with neither is only counted
549
  c.app.state.auth.cls[0]["emails"].append("a@b.c")
 
544
  assert (new["email"], new["joined"], new["recordings"]) == ("new@b.c", False, 0) and student["joined"]
545
  assert (student["classes"], new["classes"]) == ({"October 2026": ""}, {"October 2026": "דנה"})
546
  assert x["classes"] == {"October 2026": "", "September 2026": ""} and y["classes"] == {"September 2026": ""}
547
+ # a class counts what was practised since it was opened: a new one starts clean (the student's own summary stays whole)
548
+ assert student["since"]["October 2026"]["recordings"] == 2 # a class without its opening time counts everything
549
+ c.app.state.auth.cls[0]["created_at"] = "2026-10-09T00:00:00.123+00:00" # between the student's two tries
550
+ student = c.get("/api/class", headers=good).json()["students"][0]
551
+ assert student["recordings"] == 2 and student["since"] == {"October 2026": {
552
+ "best": {str(PHRASES.index(VERB)): 88}, "tries": {str(PHRASES.index(VERB)): 1}, "missed": {}, "unmatched": 0,
553
+ "recordings": 1, "days": 1,
554
+ "week": student["since"]["October 2026"]["week"], "last": "2026-10-09T10:00:00+00:00"}}
555
+ # a recording that did not match its sentence is counted apart: a recording, but no try and no score
556
+ from app.main import summary
557
+ off = summary([{"cat": VERB["cat"], "phrase": VERB["he"], "score": 12, "unrelated": True, "created_at": "2026-10-09T11:00:00+00:00"},
558
+ {"cat": VERB["cat"], "phrase": VERB["he"], "score": 70, "unrelated": False, "created_at": "2026-10-09T10:00:00+00:00"}])
559
+ assert (off["best"], off["tries"], off["missed"]) == ({PHRASES.index(VERB): 70}, {PHRASES.index(VERB): 1}, {PHRASES.index(VERB): 1})
560
+ assert (off["unmatched"], off["recordings"], off["last"]) == (1, 2, "2026-10-09T11:00:00+00:00")
561
+ c.app.state.auth.cls[0]["created_at"] = "2026-10-10T00:00:00+00:00" # opened after both
562
+ since = c.get("/api/class", headers=good).json()["students"][0]["since"]["October 2026"]
563
+ assert (since["best"], since["recordings"], since["last"]) == ({}, 0, None)
564
  assert c.delete("/api/classes/1", headers=good).status_code == 200 and len(c.app.state.auth.cls) == 1
565
  # a student sees each of their classes: by nickname, else the Google name; one with neither is only counted
566
  c.app.state.auth.cls[0]["emails"].append("a@b.c")