3v324v23 commited on
Commit
c8045f2
·
1 Parent(s): a5c9173

Refactor tests to account for lesson visibility and class lesson assignments

Browse files

- Updated assertions in `test_api_end_to_end_with_stub_model` to check for lessons that are not marked as "soon" and added checks for the number of lessons and levels.
- Modified `test_workbook_lesson_1` to filter out lessons marked as "soon" when asserting lesson IDs.
- Introduced `test_levels_plan_every_lesson` to validate lesson and level relationships, ensuring all lessons are accounted for in their respective levels.
- Enhanced `test_sign_in_guards_the_practice_endpoints` to include lesson assignments in class creation and retrieval, ensuring students see the correct lessons based on their class memberships.
- Adjusted assertions to reflect changes in class and lesson handling, ensuring proper validation of class creation and student visibility.

Files changed (7) hide show
  1. README.md +15 -3
  2. app/auth.py +11 -7
  3. app/main.py +56 -40
  4. app/phrases.py +3 -2
  5. app/workbook.py +28 -8
  6. static/index.html +348 -142
  7. tests/test_scoring.py +66 -33
README.md CHANGED
@@ -32,7 +32,7 @@ app/
32
  reference.py native baseline: the TTS voices scored by the same model
33
  phrases.py phrase bank: accepted variants (Arabic + IPA) per phrase
34
  grammar.py day-2 sentences as grammar: every pronoun × positive/negative, for the negation and conjugation buttons
35
- workbook.py the course workbook lesson by lesson: each lesson's topics (pronouns, verbs, conjugations, the Hebrew → Arabic and Arabic → Hebrew sentences)
36
  main.py FastAPI: GET / (page), GET /api/phrases, GET /api/health, POST /api/score
37
  static/index.html frontend (MediaRecorder → /api/score)
38
  train/finetune.py CTC fine-tuning on your own recordings
@@ -100,9 +100,15 @@ The page's "ההתקדמות שלי" button reads these rows back through `GET /
100
 
101
  `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.
102
 
 
 
 
 
103
  ### Classes
104
 
105
- In the panel the teacher opens a class (a name such as "אוקטובר 2026" and its students: a line each, the Google email and an optional nickname, e.g. `dana@gmail.com דנה`) and edits or deletes it later. A student is in one class; the page shows it to them under the app's name, and pressing it lists the class's students, each by their nickname 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. The panel's students are the classes' accounts, including those who have not signed in yet (without a class, every account that signed in), and it can show one class at a time. This replaces the former `ALLOWED_EMAILS`, which is no longer read.
 
 
106
 
107
  The classes are kept in Supabase and read and written with `SUPABASE_SECRET_KEY` only, so the table has row-level security and no policy. Create it once:
108
 
@@ -112,12 +118,18 @@ create table public.classes (
112
  name text not null unique, -- e.g. אוקטובר 2026
113
  emails text[] not null default '{}', -- the students' Google emails, lower-case
114
  nicknames jsonb not null default '{}', -- {email: nickname}, for the students who have one
 
115
  created_at timestamptz not null default now()
116
  );
117
  alter table public.classes enable row level security;
118
  ```
119
 
120
- A table made before the nicknames needs the column: `alter table public.classes add column nicknames jsonb not null default '{}';`
 
 
 
 
 
121
 
122
  The server keeps the list for a minute, so a change in the SQL editor (rather than the panel) applies within that time.
123
 
 
32
  reference.py native baseline: the TTS voices scored by the same model
33
  phrases.py phrase bank: accepted variants (Arabic + IPA) per phrase
34
  grammar.py day-2 sentences as grammar: every pronoun × positive/negative, for the negation and conjugation buttons
35
+ workbook.py the course workbook lesson by lesson: each lesson's topics (pronouns, verbs, conjugations, the Hebrew → Arabic and Arabic → Hebrew sentences), and the levels
36
  main.py FastAPI: GET / (page), GET /api/phrases, GET /api/health, POST /api/score
37
  static/index.html frontend (MediaRecorder → /api/score)
38
  train/finetune.py CTC fine-tuning on your own recordings
 
100
 
101
  `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.
102
 
103
+ ### Levels
104
+
105
+ The course has levels, each with a planned number of lessons (`LEVELS` in `app/workbook.py`: רמה 1 and רמה 2, 10 lessons each). Every lesson in `LESSONS` is keyed by its level and number, `(level, lesson)`; one outside the plan stops the app at startup. The path shows each level under its name, and the lessons not written yet as "בקרוב". Level 1's lessons keep their first ids ("1", topics "l1-…") so recorded attempts and saved classes still match; later levels' are "2.1", topics "l2.1-…".
106
+
107
  ### Classes
108
 
109
+ 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.
110
+
111
+ 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.
112
 
113
  The classes are kept in Supabase and read and written with `SUPABASE_SECRET_KEY` only, so the table has row-level security and no policy. Create it once:
114
 
 
118
  name text not null unique, -- e.g. אוקטובר 2026
119
  emails text[] not null default '{}', -- the students' Google emails, lower-case
120
  nicknames jsonb not null default '{}', -- {email: nickname}, for the students who have one
121
+ lessons text[] not null default '{}', -- the lesson ids the class studies; empty = every lesson
122
  created_at timestamptz not null default now()
123
  );
124
  alter table public.classes enable row level security;
125
  ```
126
 
127
+ A table made before the nicknames or the lessons needs the columns (a class saved before then studies every lesson):
128
+
129
+ ```sql
130
+ alter table public.classes add column if not exists nicknames jsonb not null default '{}',
131
+ add column if not exists lessons text[] not null default '{}';
132
+ ```
133
 
134
  The server keeps the list for a minute, so a change in the SQL editor (rather than the panel) applies within that time.
135
 
app/auth.py CHANGED
@@ -10,8 +10,9 @@ Teachers: the accounts in TEACHER_EMAILS also get the management panel, which sh
10
  progress. Those rows are read with the project's secret key, which passes row-level security and
11
  never leaves the server.
12
 
13
- Classes: the teacher opens classes in the panel (a name, e.g. "October 2026", its students' emails and an
14
- optional nickname of each), kept in the Supabase table `classes`. Once there is a class, only its students and the teachers are let in;
 
15
  before the first one (or without the secret key, which reads the table) any Google account is.
16
 
17
  Env: SUPABASE_URL (https://<project-ref>.supabase.co)
@@ -143,7 +144,8 @@ class SupabaseAuth:
143
  raise AuthUnavailable(str(e)) from e
144
 
145
  def classes(self, fresh: bool = False) -> list[dict]:
146
- """Every class, newest first: id, name, emails (lower-case), nicknames ({email: nickname}), created_at.
 
147
  Kept for CLASSES_TTL, as each sign-in
148
  check asks. Without the secret key, or before the table is created, there are none."""
149
  now = time.monotonic()
@@ -157,7 +159,7 @@ class SupabaseAuth:
157
  # every column: a table made before the nicknames still lets its students in
158
  rows = self._admin("/rest/v1/classes?select=*&order=created_at.desc")
159
  for r in rows:
160
- r["nicknames"] = r.get("nicknames") or {}
161
  except AuthUnavailable as e:
162
  if "answered 404" not in str(e):
163
  raise
@@ -166,9 +168,11 @@ class SupabaseAuth:
166
  self._classes = (now + CLASSES_TTL, rows)
167
  return rows
168
 
169
- def save_class(self, id: int | None, name: str, emails: list[str], nicknames: dict[str, str]) -> None:
170
- """Adds a class (id None) or replaces one's name, emails and nicknames. A name already taken raises ClassConflict."""
171
- body = {"name": name, "emails": emails, "nicknames": nicknames}
 
 
172
  if id is None:
173
  self._admin("/rest/v1/classes", "POST", body)
174
  elif not self._admin(f"/rest/v1/classes?id=eq.{int(id)}", "PATCH", body):
 
10
  progress. Those rows are read with the project's secret key, which passes row-level security and
11
  never leaves the server.
12
 
13
+ Classes: the teacher opens classes in the panel (a name, e.g. "October 2026", the lessons it studies, its
14
+ students' emails and an optional nickname of each), kept in the Supabase table `classes`. A student may be in
15
+ several. Once there is a class, only the classes' students and the teachers are let in;
16
  before the first one (or without the secret key, which reads the table) any Google account is.
17
 
18
  Env: SUPABASE_URL (https://<project-ref>.supabase.co)
 
144
  raise AuthUnavailable(str(e)) from e
145
 
146
  def classes(self, fresh: bool = False) -> list[dict]:
147
+ """Every class, newest first: id, name, emails (lower-case), nicknames ({email: nickname}), lessons (lesson ids;
148
+ empty in a class saved before classes had lessons), created_at.
149
  Kept for CLASSES_TTL, as each sign-in
150
  check asks. Without the secret key, or before the table is created, there are none."""
151
  now = time.monotonic()
 
159
  # every column: a table made before the nicknames still lets its students in
160
  rows = self._admin("/rest/v1/classes?select=*&order=created_at.desc")
161
  for r in rows:
162
+ r["nicknames"], r["lessons"] = r.get("nicknames") or {}, r.get("lessons") or []
163
  except AuthUnavailable as e:
164
  if "answered 404" not in str(e):
165
  raise
 
168
  self._classes = (now + CLASSES_TTL, rows)
169
  return rows
170
 
171
+ def save_class(self, id: int | None, name: str, emails: list[str], nicknames: dict[str, str],
172
+ lessons: list[str]) -> None:
173
+ """Adds a class (id None) or replaces one's name, emails, nicknames and lessons. A name already taken raises
174
+ ClassConflict."""
175
+ body = {"name": name, "emails": emails, "nicknames": nicknames, "lessons": lessons}
176
  if id is None:
177
  self._admin("/rest/v1/classes", "POST", body)
178
  elif not self._admin(f"/rest/v1/classes?id=eq.{int(id)}", "PATCH", body):
app/main.py CHANGED
@@ -26,7 +26,7 @@ from fastapi.responses import HTMLResponse, Response
26
  from fastapi.staticfiles import StaticFiles
27
 
28
  from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
29
- from .phrases import CATEGORIES, LESSONS, PHRASES, public_phrases
30
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
31
  from .reference import References
32
  from .scorer import Scorer, decode_audio, load_scorer
@@ -41,6 +41,14 @@ LOCAL_TZ = ZoneInfo("Asia/Jerusalem") # a day of practice is the students' cale
41
  MAX_UPLOAD = 2_000_000 # bytes; 15 s of opus is ~100 KB
42
  EMAIL = re.compile(r"[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+")
43
  MAX_NICKNAME = 40
 
 
 
 
 
 
 
 
44
 
45
 
46
  def class_members(text: str) -> tuple[list[str], dict[str, str]]:
@@ -124,11 +132,11 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
124
  classes = a.classes()
125
  except AuthUnavailable as e:
126
  raise HTTPException(503, f"class list not available: {e}")
127
- klass = next((c["name"] for c in classes if email in c["emails"]), "")
128
  teacher = email in a.teachers
129
- if classes and not klass and not teacher: # before the first class, any account is let in
130
  raise HTTPException(403, "this account is not on the list")
131
- return {**user, "token": token.strip(), "teacher": teacher, "class": klass}
132
 
133
  def teacher_only(user: dict | None = Depends(signed_in)) -> SupabaseAuth:
134
  if not user or not user["teacher"]:
@@ -148,9 +156,14 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
148
 
149
  @app.get("/api/me")
150
  def me(user: dict | None = Depends(signed_in)):
151
- """Who is signed in, and their class; the page asks this right after sign-in to learn whether the account is let in."""
 
 
 
 
 
152
  return {"email": user["email"] if user else "", "teacher": bool(user and user["teacher"]),
153
- "class": user["class"] if user else ""}
154
 
155
  @app.get("/api/progress")
156
  def progress(user: dict | None = Depends(signed_in)):
@@ -160,9 +173,9 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
160
 
161
  @app.get("/api/class")
162
  def class_progress(a: SupabaseAuth = Depends(teacher_only)):
163
- """The teacher's panel: the classes, and every student's summary (see `summary`) with their class. The
164
- students are the classes' accounts, or every account that signed in when there is no class yet; one that
165
- never signed in has joined = false."""
166
  try:
167
  classes, users, rows = a.classes(fresh=True), a.users(), a.all_attempts()
168
  except AuthUnavailable as e:
@@ -170,43 +183,45 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
170
  by: dict[str, list[dict]] = {}
171
  for r in rows:
172
  by.setdefault(r["user_id"], []).append(r)
173
- member = {e: c for c in reversed(classes) for e in c["emails"]}
174
- klass = lambda e: {"class": member[e]["name"], "nick": member[e]["nicknames"].get(e, "")} if e in member \
175
- else {"class": "", "nick": ""}
176
- students = [{"email": u["email"], "name": u["name"], "joined": True, **klass(u["email"].lower()),
 
177
  **summary(by.get(u["id"], []))} for u in users if not classes or u["email"].lower() in member]
178
  missing = member.keys() - {s["email"].lower() for s in students}
179
- students += [{"email": e, "name": "", "joined": False, **klass(e), **summary([])} for e in sorted(missing)]
180
- return {"classes": classes, "students": students}
181
 
182
  def class_store_error(e: AuthUnavailable) -> str:
183
  """The teacher's message when a class cannot be written; a 404 means Supabase has no `classes` table."""
184
  if "answered 404" in str(e):
185
  return ("הטבלה classes לא קיימת ב-Supabase. צרו אותה ב-SQL Editor (הפקודה ב-README); "
186
  "אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
187
- if "nicknames" in str(e): # a table made before the nicknames
188
- return ("לטבלה classes חסרה העמודה nicknames. הריצו ב-SQL Editor של Supabase: "
189
- "alter table public.classes add column nicknames jsonb not null default '{}';")
190
  return f"Supabase לא זמין: {e}"
191
 
192
  def save_class(a: SupabaseAuth, id: int | None, body: dict) -> dict:
193
- """Checks a class from the panel ({"name", "emails": the student list, see `class_members`}) and stores it. A
194
- student is in one class: an address that another class already has is refused, by name, so the teacher moves
195
- it on purpose."""
196
- name = str(body.get("name", "")).strip()
197
  if not name or len(name) > 80:
198
  raise HTTPException(400, "לכיתה צריך שם (עד 80 תווים)")
199
  try:
200
  emails, nicks = class_members(str(body.get("emails", "")))
201
  except ValueError as e:
202
  raise HTTPException(400, f"בשורה הזו אין כתובת מייל: {e}")
 
 
 
 
203
  try:
204
- others = [c for c in a.classes(fresh=True) if c["id"] != id]
205
- if taken := sorted(f"{e} ({c['name']})" for c in others for e in emails if e in c["emails"]):
206
- raise HTTPException(409, f"כבר בכיתה אחרת: {', '.join(taken)}")
207
- if any(c["name"] == name for c in others):
208
- raise HTTPException(409, f"כבר יש כיתה בשם {name}")
209
- a.save_class(id, name, emails, nicks)
210
  except ClassConflict:
211
  raise HTTPException(409, f"כבר יש כיתה בשם {name}")
212
  except KeyError:
@@ -233,25 +248,26 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
233
 
234
  @app.get("/api/classmates")
235
  def classmates(user: dict | None = Depends(signed_in)):
236
- """The signed-in student's class: its name and its students, each by the nickname the teacher gave, or else
237
- the name Google gave (one with neither, who has not signed in yet, is only counted). No addresses."""
238
- if not user or not user["class"]:
239
- return {"class": "", "students": [], "unnamed": 0}
240
- a: SupabaseAuth = app.state.auth
241
- c = next(c for c in a.classes() if c["name"] == user["class"])
242
  try:
243
- google = {u["email"].lower(): u["name"] for u in a.users()}
244
  except AuthUnavailable as e: # the nicknames are still known
245
  log.warning("classmates' names not read: %s", e)
246
  google = {}
247
- me = user["email"].lower()
248
- named = [{"name": c["nicknames"].get(e) or google.get(e, ""), "me": e == me} for e in c["emails"]]
249
- students = sorted((s for s in named if s["name"]), key=lambda s: s["name"])
250
- return {"class": c["name"], "students": students, "unnamed": len(named) - len(students)}
 
 
251
 
252
  @app.get("/api/phrases")
253
  def phrases():
254
- return {"lessons": LESSONS, "categories": CATEGORIES, "phrases": public_phrases(),
255
  "voices": [{"id": i, "name": n} for n, i in VOICES.items()]}
256
 
257
  @app.get("/api/health")
 
26
  from fastapi.staticfiles import StaticFiles
27
 
28
  from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
29
+ from .phrases import CATEGORIES, LESSONS, LEVELS, PHRASES, public_phrases
30
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
31
  from .reference import References
32
  from .scorer import Scorer, decode_audio, load_scorer
 
41
  MAX_UPLOAD = 2_000_000 # bytes; 15 s of opus is ~100 KB
42
  EMAIL = re.compile(r"[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+")
43
  MAX_NICKNAME = 40
44
+ ALL_LESSONS = [l["id"] for l in LESSONS] # with the lessons still to come: a class may take them ahead
45
+ MIGRATE_CLASSES = ("alter table public.classes add column if not exists nicknames jsonb not null default '{}', "
46
+ "add column if not exists lessons text[] not null default '{}';")
47
+
48
+
49
+ def lessons_of(c: dict) -> list[str]:
50
+ """A class's lessons in the course's order; a class saved before classes had lessons has every one."""
51
+ return [l for l in ALL_LESSONS if l in c["lessons"]] or ALL_LESSONS
52
 
53
 
54
  def class_members(text: str) -> tuple[list[str], dict[str, str]]:
 
132
  classes = a.classes()
133
  except AuthUnavailable as e:
134
  raise HTTPException(503, f"class list not available: {e}")
135
+ mine = [c for c in classes if email in c["emails"]] # a student may be in several classes
136
  teacher = email in a.teachers
137
+ if classes and not mine and not teacher: # before the first class, any account is let in
138
  raise HTTPException(403, "this account is not on the list")
139
+ return {**user, "token": token.strip(), "teacher": teacher, "classes": mine}
140
 
141
  def teacher_only(user: dict | None = Depends(signed_in)) -> SupabaseAuth:
142
  if not user or not user["teacher"]:
 
156
 
157
  @app.get("/api/me")
158
  def me(user: dict | None = Depends(signed_in)):
159
+ """Who is signed in, their classes (each with its lessons: a student in several chooses one to practise in), and
160
+ the lessons of all of them together (every lesson for a teacher, and for an account in no class). The page
161
+ asks this right after sign-in to learn whether the account is let in."""
162
+ mine = user["classes"] if user else []
163
+ lessons = ALL_LESSONS if not mine or user["teacher"] else \
164
+ [l for l in ALL_LESSONS if any(l in lessons_of(c) for c in mine)]
165
  return {"email": user["email"] if user else "", "teacher": bool(user and user["teacher"]),
166
+ "classes": [{"name": c["name"], "lessons": lessons_of(c)} for c in mine], "lessons": lessons}
167
 
168
  @app.get("/api/progress")
169
  def progress(user: dict | None = Depends(signed_in)):
 
173
 
174
  @app.get("/api/class")
175
  def class_progress(a: SupabaseAuth = Depends(teacher_only)):
176
+ """The teacher's panel: the classes (with their lessons), and every student's summary (see `summary`) with
177
+ their classes ({name: nickname}). The students are the classes' accounts, or every account that signed in
178
+ when there is no class yet; one that never signed in has joined = false."""
179
  try:
180
  classes, users, rows = a.classes(fresh=True), a.users(), a.all_attempts()
181
  except AuthUnavailable as e:
 
183
  by: dict[str, list[dict]] = {}
184
  for r in rows:
185
  by.setdefault(r["user_id"], []).append(r)
186
+ member: dict[str, dict[str, str]] = {} # email -> {class name: nickname}
187
+ for c in classes:
188
+ for e in c["emails"]:
189
+ member.setdefault(e, {})[c["name"]] = c["nicknames"].get(e, "")
190
+ students = [{"email": u["email"], "name": u["name"], "joined": True, "classes": member.get(u["email"].lower(), {}),
191
  **summary(by.get(u["id"], []))} for u in users if not classes or u["email"].lower() in member]
192
  missing = member.keys() - {s["email"].lower() for s in students}
193
+ students += [{"email": e, "name": "", "joined": False, "classes": member[e], **summary([])} for e in sorted(missing)]
194
+ return {"classes": [{**c, "lessons": lessons_of(c)} for c in classes], "students": students}
195
 
196
  def class_store_error(e: AuthUnavailable) -> str:
197
  """The teacher's message when a class cannot be written; a 404 means Supabase has no `classes` table."""
198
  if "answered 404" in str(e):
199
  return ("הטבלה classes לא קיימת ב-Supabase. צרו אותה ב-SQL Editor (הפקודה ב-README); "
200
  "אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
201
+ if "nicknames" in str(e) or "lessons" in str(e): # a table made before the nicknames or the lessons
202
+ return f"לטבלה classes חסרות עמודות. הריצו ב-SQL Editor של Supabase: {MIGRATE_CLASSES}"
 
203
  return f"Supabase לא זמין: {e}"
204
 
205
  def save_class(a: SupabaseAuth, id: int | None, body: dict) -> dict:
206
+ """Checks a class from the panel ({"name", "emails": the student list, see `class_members`, "lessons": lesson
207
+ ids}) and stores it. A student may be in several classes, e.g. a beginners' class and an advanced one."""
208
+ name = " ".join(str(body.get("name", "")).split()) # one space between words, none around
 
209
  if not name or len(name) > 80:
210
  raise HTTPException(400, "לכיתה צריך שם (עד 80 תווים)")
211
  try:
212
  emails, nicks = class_members(str(body.get("emails", "")))
213
  except ValueError as e:
214
  raise HTTPException(400, f"בשורה הזו אין כתובת מייל: {e}")
215
+ lessons = body.get("lessons")
216
+ if not isinstance(lessons, list) or not lessons or not set(map(str, lessons)) <= set(ALL_LESSONS):
217
+ raise HTTPException(400, "בחרו לפחות שיעור אחד לכיתה")
218
+ lessons = [l for l in ALL_LESSONS if l in map(str, lessons)]
219
  try:
220
+ # a class's name is its own: the same words in other letter case or spacing are the same name
221
+ same = lambda other: " ".join(other.split()).casefold() == name.casefold()
222
+ if taken := next((c["name"] for c in a.classes(fresh=True) if c["id"] != id and same(c["name"])), None):
223
+ raise HTTPException(409, f"כבר יש כיתה בשם {taken}")
224
+ a.save_class(id, name, emails, nicks, lessons)
 
225
  except ClassConflict:
226
  raise HTTPException(409, f"כבר יש כיתה בשם {name}")
227
  except KeyError:
 
248
 
249
  @app.get("/api/classmates")
250
  def classmates(user: dict | None = Depends(signed_in)):
251
+ """The signed-in student's classes: each one's name and its students, each by the nickname the teacher gave
252
+ in that class, or else the name Google gave (one with neither, who has not signed in yet, is only counted).
253
+ No addresses."""
254
+ if not user or not user["classes"]:
255
+ return {"classes": []}
 
256
  try:
257
+ google = {u["email"].lower(): u["name"] for u in app.state.auth.users()}
258
  except AuthUnavailable as e: # the nicknames are still known
259
  log.warning("classmates' names not read: %s", e)
260
  google = {}
261
+ me, out = user["email"].lower(), []
262
+ for c in user["classes"]:
263
+ named = [{"name": c["nicknames"].get(e) or google.get(e, ""), "me": e == me} for e in c["emails"]]
264
+ students = sorted((s for s in named if s["name"]), key=lambda s: s["name"])
265
+ out.append({"name": c["name"], "students": students, "unnamed": len(named) - len(students)})
266
+ return {"classes": out}
267
 
268
  @app.get("/api/phrases")
269
  def phrases():
270
+ return {"levels": LEVELS, "lessons": LESSONS, "categories": CATEGORIES, "phrases": public_phrases(),
271
  "voices": [{"id": i, "name": n} for n, i in VOICES.items()]}
272
 
273
  @app.get("/api/health")
app/phrases.py CHANGED
@@ -12,7 +12,7 @@ Conventions (Palestinian urban speech as the main form):
12
  Geminates are written doubled; the tokenizer collapses them for CTC.
13
  """
14
  from .grammar import day2_phrases
15
- from .workbook import CATEGORIES as WORKBOOK_CATEGORIES, LESSON_TABS, workbook_phrases
16
 
17
  PHRASES = [
18
  # ---- היכרות ----
@@ -131,7 +131,8 @@ PHRASES += workbook_phrases()
131
  # The page asks for a lesson first, then one of its categories: the course's lessons (workbook.py).
132
  # The phrases that belong to no lesson (meet, talk, city, food, day2) have no tab. They stay in the
133
  # bank, so phrase ids and the attempts recorded on them hold; public_phrases leaves them out.
134
- LESSONS = [*LESSON_TABS]
 
135
  CATEGORIES = [*WORKBOOK_CATEGORIES]
136
 
137
 
 
12
  Geminates are written doubled; the tokenizer collapses them for CTC.
13
  """
14
  from .grammar import day2_phrases
15
+ from .workbook import CATEGORIES as WORKBOOK_CATEGORIES, LESSON_TABS, LEVEL_TABS, workbook_phrases
16
 
17
  PHRASES = [
18
  # ---- היכרות ----
 
131
  # The page asks for a lesson first, then one of its categories: the course's lessons (workbook.py).
132
  # The phrases that belong to no lesson (meet, talk, city, food, day2) have no tab. They stay in the
133
  # bank, so phrase ids and the attempts recorded on them hold; public_phrases leaves them out.
134
+ LEVELS = [*LEVEL_TABS]
135
+ LESSONS = [*LESSON_TABS] # every planned lesson; one not written yet is `soon` and has no categories
136
  CATEGORIES = [*WORKBOOK_CATEGORIES]
137
 
138
 
app/workbook.py CHANGED
@@ -268,9 +268,14 @@ def _possessives(nouns):
268
  for he, stem in nouns for owner, say, suffix, tag, *_ in OWNERS]
269
 
270
 
271
- # lesson -> its topics: (id, name, kind, shown in order, [(Hebrew, Arabic) or (Hebrew, Arabic, Hebrew as read aloud)]); sentence numbering is the workbook's
 
 
 
 
 
272
  LESSONS = {
273
- 1: [
274
  ("pron", "הגופים", "say", True, [(_plain(he) + " / הן" * (he == "הֵם"), ar, he) for he, ar in zip(HE_PRONOUNS, AR_PRONOUNS)]),
275
  ("verbs", "פעלים", "say", True, _base_forms(VERBS_1)),
276
  ("past", "הטיות בעבר", "say", False, _past(VERBS_1)),
@@ -311,7 +316,7 @@ LESSONS = {
311
  ("המורה שאלה את הילדה: \"חרוצה, למה ישבת על השולחן?\"", "المعلّمة سألت#f البنت: يا شاطرة ليش قعدتي على الطّاولة؟"),
312
  ]),
313
  ],
314
- 2: [
315
  ("dual", "הזוגי", "say", True, [(f"{he}, {he2}", f"{ar}، {ar2}") for he, he2, ar, ar2 in DUALS]),
316
  ("family", "המשפחה", "say", True, FAMILY),
317
  ("poss", "כינויי הקניין", "say", False, _possessives(POSSESSIVE_DRILL)),
@@ -439,7 +444,21 @@ LESSONS = {
439
  ]),
440
  ],
441
  }
442
- LESSON_TABS = [{"id": str(n), "name": f"שיעור {n}"} for n in LESSONS]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  # Topics the page lays out as a story: a conversation, all its lines on the card, said one after the other.
444
  # Each has its two speakers' voices, by name in the voice list: the odd lines are the first one's, the even the second's.
445
  STORIES = {"l2-talk": ("אמיר", "ראאף")}
@@ -448,9 +467,10 @@ STORIES = {"l2-talk": ("אמיר", "ראאף")}
448
  UNITS = {"pron": "מילה", "verbs": "פועל", "dual": "מילה", "family": "מילה"}
449
  TASKS = {"verbs": "אמרו את הפועל בערבית מדוברת: בעבר ואז בעתיד",
450
  "dual": "אמרו בערבית מדוברת: ביחיד ואז בזוגי"}
451
- CATEGORIES = [{"id": f"l{n}-{tid}", "name": name, "lesson": str(n), **({"story": True, "voices": list(STORIES[f"l{n}-{tid}"])} if f"l{n}-{tid}" in STORIES else {}),
 
452
  **({"unit": UNITS[tid]} if tid in UNITS else {}), **({"task": TASKS[tid]} if tid in TASKS else {})}
453
- for n, topics in LESSONS.items() for tid, name, *_ in topics]
454
 
455
 
456
  def _word(token: str) -> tuple[str, str, list[str]]:
@@ -489,11 +509,11 @@ def workbook_phrases() -> list[dict]:
489
  `read` marks the Arabic -> Hebrew ones, where the page shows `tr` and hides `he`;
490
  `he_say` is the pointed Hebrew for the prompt voice, where the plain text is misread."""
491
  out = []
492
- for n, topics in LESSONS.items():
493
  for tid, _, kind, in_order, items in topics:
494
  for seq, (he, sentence, *he_say) in enumerate(items, 1):
495
  words = [_word(t) for t in sentence.split()]
496
- phrase = dict(cat=f"l{n}-{tid}", he=he, tr=" ".join(t for _, t, _ in words),
497
  variants=_variants(words), read=kind == "read")
498
  say = _voiced(he_say[0] if he_say else he)
499
  if say != he:
 
268
  for he, stem in nouns for owner, say, suffix, tag, *_ in OWNERS]
269
 
270
 
271
+ # The course's levels: level -> (name, how many lessons it will have). A level's lessons that are not in LESSONS yet
272
+ # are shown on the path as "בקרוב".
273
+ LEVELS = {1: ("רמה 1", 10), 2: ("רמה 2", 10)}
274
+
275
+ # (level, lesson) -> its topics: (id, name, kind, shown in order, [(Hebrew, Arabic) or (Hebrew, Arabic, Hebrew as read
276
+ # aloud)]); sentence numbering is the workbook's. Every lesson names its level.
277
  LESSONS = {
278
+ (1, 1): [
279
  ("pron", "הגופים", "say", True, [(_plain(he) + " / הן" * (he == "הֵם"), ar, he) for he, ar in zip(HE_PRONOUNS, AR_PRONOUNS)]),
280
  ("verbs", "פעלים", "say", True, _base_forms(VERBS_1)),
281
  ("past", "הטיות בעבר", "say", False, _past(VERBS_1)),
 
316
  ("המורה שאלה את הילדה: \"חרוצה, למה ישבת על השולחן?\"", "المعلّمة سألت#f البنت: يا شاطرة ليش قعدتي على الطّاولة؟"),
317
  ]),
318
  ],
319
+ (1, 2): [
320
  ("dual", "הזוגי", "say", True, [(f"{he}, {he2}", f"{ar}، {ar2}") for he, he2, ar, ar2 in DUALS]),
321
  ("family", "המשפחה", "say", True, FAMILY),
322
  ("poss", "כינויי הקניין", "say", False, _possessives(POSSESSIVE_DRILL)),
 
444
  ]),
445
  ],
446
  }
447
+ for _level, _n in LESSONS:
448
+ assert _level in LEVELS and 1 <= _n <= LEVELS[_level][1], f"lesson {_n} of level {_level} is not in LEVELS"
449
+
450
+
451
+ def lesson_id(level: int, n: int) -> str:
452
+ """Level 1's lessons keep their first ids ("1", and its topics "l1-..."): recorded attempts and saved classes name
453
+ them. Later levels' are "2.1" (topics "l2.1-...")."""
454
+ return str(n) if level == 1 else f"{level}.{n}"
455
+
456
+
457
+ LEVEL_TABS = [{"id": str(level), "name": name} for level, (name, _) in LEVELS.items()]
458
+ # every planned lesson, in the course's order; one not written yet is `soon`
459
+ LESSON_TABS = [{"id": lesson_id(level, n), "name": f"שיעור {n}", "level": str(level), "n": n,
460
+ **({} if (level, n) in LESSONS else {"soon": True})}
461
+ for level, (_, count) in LEVELS.items() for n in range(1, count + 1)]
462
  # Topics the page lays out as a story: a conversation, all its lines on the card, said one after the other.
463
  # Each has its two speakers' voices, by name in the voice list: the odd lines are the first one's, the even the second's.
464
  STORIES = {"l2-talk": ("אמיר", "ראאף")}
 
467
  UNITS = {"pron": "מילה", "verbs": "פועל", "dual": "מילה", "family": "מילה"}
468
  TASKS = {"verbs": "אמרו את הפועל בערבית מדוברת: בעבר ואז בעתיד",
469
  "dual": "אמרו בערבית מדוברת: ביחיד ואז בזוגי"}
470
+ CATEGORIES = [{"id": f"l{lesson_id(*key)}-{tid}", "name": name, "lesson": lesson_id(*key),
471
+ **({"story": True, "voices": list(STORIES[f"l{lesson_id(*key)}-{tid}"])} if f"l{lesson_id(*key)}-{tid}" in STORIES else {}),
472
  **({"unit": UNITS[tid]} if tid in UNITS else {}), **({"task": TASKS[tid]} if tid in TASKS else {})}
473
+ for key, topics in LESSONS.items() for tid, name, *_ in topics]
474
 
475
 
476
  def _word(token: str) -> tuple[str, str, list[str]]:
 
509
  `read` marks the Arabic -> Hebrew ones, where the page shows `tr` and hides `he`;
510
  `he_say` is the pointed Hebrew for the prompt voice, where the plain text is misread."""
511
  out = []
512
+ for key, topics in LESSONS.items():
513
  for tid, _, kind, in_order, items in topics:
514
  for seq, (he, sentence, *he_say) in enumerate(items, 1):
515
  words = [_word(t) for t in sentence.split()]
516
+ phrase = dict(cat=f"l{lesson_id(*key)}-{tid}", he=he, tr=" ".join(t for _, t, _ in words),
517
  variants=_variants(words), read=kind == "read")
518
  say = _voiced(he_say[0] if he_say else he)
519
  if say != he:
static/index.html CHANGED
@@ -35,6 +35,16 @@ header{display:flex;align-items:center;gap:4px 12px;flex-wrap:wrap}
35
  .mates{list-style:none;margin:0;padding:0;columns:2 10em;column-gap:16px}
36
  .mates li{padding:7px 2px;border-bottom:1px solid var(--line);font-size:16px;break-inside:avoid}
37
  .mates li.me{font-weight:700;color:var(--olive)}
 
 
 
 
 
 
 
 
 
 
38
  .sub{color:var(--muted);font-size:14px}
39
  /* on the practice card the header names where the student is: the lesson, and under it the topic */
40
  .where{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;font:400 22px/1.15 var(--f-display)}
@@ -57,6 +67,10 @@ header .sub.maphint{flex:0 0 100%;color:var(--ink)}
57
  .chapter i::after{content:"";display:block;width:var(--p,0%);height:100%;border-radius:999px;background:var(--on-olive)}
58
  .chapter.gold{background:var(--amber-bg);color:var(--amber);box-shadow:0 4px 0 var(--amber)}
59
  .chapter:active{transform:translateY(3px);box-shadow:0 1px 0 var(--olive-deep)}
 
 
 
 
60
  .sat{--c:var(--olive);--t:6px;--s:4px;position:absolute;left:calc(50% + var(--x));top:var(--y);width:72px;height:72px;margin:-36px 0 0 -36px;padding:0;border:0;background:none;color:var(--ink);font:600 15px/1.25 var(--f-body);cursor:pointer}
61
  .ring{display:block;position:relative;width:72px;height:72px;padding:10px 10px 14px;border-radius:50%;background:radial-gradient(closest-side,var(--bg) calc(100% - var(--t)),transparent calc(100% - var(--t) + 1px)),conic-gradient(var(--c) var(--p,0%),var(--track) 0)}
62
  .dot{display:grid;place-items:center;width:100%;height:100%;border-radius:50%;background:var(--surface);color:var(--ink);font:400 22px/1 var(--f-display);box-shadow:0 var(--s) 0 var(--track);transition:transform .08s,box-shadow .08s}
@@ -146,21 +160,12 @@ header .sub.maphint{flex:0 0 100%;color:var(--ink)}
146
  .tips .what .ar{font-family:var(--f-ar);font-size:14px;font-weight:400;color:var(--muted)}
147
  #denied{text-align:center;font:600 18px/1.5 var(--f-body);color:#1C241D;padding:0 20px}
148
  #denied button{display:block;margin:12px auto 0;font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
149
- /* The teacher's panel: a card per student (name, grade, how much was practised and when) that opens on its topics. */
150
  body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage #toNow,body.manage #practice,body:not(.home) #toManage{display:none}
151
  .mtitle{font:400 24px/1 var(--f-display);margin:0}
152
- #mList{display:flex;flex-direction:column;gap:10px}
153
- .stu{display:block;padding:0}
154
- .stu summary{cursor:pointer;list-style:none}
155
- .stu>summary{display:grid;grid-template-columns:1fr auto;gap:6px 10px;align-items:center;padding:12px 16px}
156
- .stu summary::-webkit-details-marker{display:none}
157
- .stu .who{display:flex;flex-direction:column;align-items:flex-start;min-width:0}
158
- .stu .who b{font-weight:700;overflow-wrap:anywhere}
159
- .stu .who small,.stu .stats{color:var(--muted);font-size:14px}
160
- .stu .none{color:var(--muted);font-size:13px}
161
- .stu .stats,.stu .progress{grid-column:1/-1;margin:0}
162
  /* inside a student, each part (the best sentences, the weakest, every lesson) opens and closes on its heading */
163
- .sec>summary{display:grid;grid-template-columns:1fr auto 3em;gap:4px 10px;align-items:center;padding:8px 16px;border-top:1px solid var(--line);font:700 15px/1.4 var(--f-body)}
164
  .sec>summary>:first-child::before{content:"◂";display:inline-block;margin-inline-end:6px;color:var(--muted);transition:transform .12s}
165
  .sec[open]>summary>:first-child::before{transform:rotate(-90deg)}
166
  .sec>summary small{font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums}
@@ -175,17 +180,69 @@ body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage
175
  .hard h2 small{display:block;margin-top:2px;font:600 13px/1.3 var(--f-body);color:var(--muted)}
176
  .hard .trow{grid-template-columns:1fr auto auto 3em;border-top:1px solid var(--line);padding-block:7px}
177
  .trow .two{display:flex;flex-direction:column;font-weight:600}
178
- .stu .trow.s3{grid-template-columns:1fr auto 3em}
179
- /* the classes: each opens on a form with its name and its students' emails; the last one opens a new class */
180
- .cls .sec>summary{grid-template-columns:1fr auto}
181
- .cls .note{margin:0;padding:0 16px 8px;color:var(--muted);font-size:14px}
182
- .cform{display:flex;flex-direction:column;gap:8px;padding:6px 16px 4px}
183
- .cform label{display:flex;flex-direction:column;gap:4px;font:600 14px/1.3 var(--f-body);color:var(--muted)}
184
- .cform input,.cform textarea,#mFilter{font:500 15px/1.4 var(--f-body);padding:6px 10px;border-radius:8px;border:1px solid var(--line);background:var(--bg);color:var(--ink)}
185
- .cform textarea{min-height:7em;resize:vertical;direction:ltr;text-align:left}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  .cform .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
187
- .cform .msg{color:var(--sumac);font-size:14px}
188
- .stu .who .klass{color:var(--olive);font-weight:700;font-size:13px}
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  #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}
190
  .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)}
191
  body.driving .drive{background:var(--sumac);border-color:var(--sumac);color:#fff}
@@ -276,7 +333,7 @@ body.driving .card.storymode .prompt{display:block}
276
  <div id="logo-splash"><img src="static/logo-intro.svg" alt="אהלא וסהלא, בית הספר ללימוד השפה הערבית" width="489" height="225"><button id="startBtn">יאללה נבלש</button><div id="roles" hidden><button id="practiceBtn">תרגול</button><button id="manageBtn">ניהול</button></div><button id="loginBtn" hidden>כניסה עם Google</button><div id="denied" hidden>החשבון הזה אינו באף כיתה. בקשו מהמורה להוסיף אותו.<button id="switchBtn">כניסה עם חשבון אחר</button></div></div>
277
  <div class="wrap" id="wrap" inert>
278
  <header>
279
- <div class="brand">אהלא וסהלא<button id="myClass" title="התלמידים בכיתה שלי"></button></div>
280
  <div class="where"><small id="lessonName"></small><span id="catName"></span></div>
281
  <div class="sub maphint">בחרו נושא במסלול ותרגלו אותו בקול</div>
282
  <button class="drive" id="toManage" hidden>ניהול</button>
@@ -338,11 +395,12 @@ body.driving .card.storymode .prompt{display:block}
338
  </div>
339
 
340
  <section class="wrap" id="manage" aria-labelledby="mTitle">
341
- <div class="roundbar"><h1 class="mtitle" id="mTitle">ניהול</h1><span class="sep"></span><select id="mFilter" aria-label="הכיתה שמוצגת" hidden></select><button class="btn" id="mRefresh">רענון</button><button class="btn primary" id="mPractice">לתרגול</button></div>
342
  <div class="sub" id="mState" aria-live="polite"></div>
343
- <div class="card hard cls" id="mClasses" hidden></div>
344
- <div class="card hard" id="mHard" hidden></div>
345
- <div id="mList"></div>
 
346
  </section>
347
  </div>
348
  <dialog id="listDlg" aria-labelledby="listTitle">
@@ -350,10 +408,25 @@ body.driving .card.storymode .prompt{display:block}
350
  <label class="donebox"><input type="checkbox" id="showDone"><span id="doneText"></span></label>
351
  <ol class="plist" id="plist"></ol>
352
  </dialog>
 
 
 
 
 
 
 
 
 
 
353
  <dialog id="classDlg" aria-labelledby="classTitle">
354
  <div class="dlg-head"><h2 id="classTitle"></h2><button class="btn" id="classClose">סגירה</button></div>
355
- <ul class="mates" id="mates"></ul>
356
  <p class="sub" id="matesNote"></p>
 
 
 
 
 
357
  </dialog>
358
  <dialog id="driveDlg" aria-labelledby="driveTitle">
359
  <div class="dlg-head"><h2 id="driveTitle">מצב נסיעה</h2></div>
@@ -385,7 +458,9 @@ async function api(url,opts={}){ // fetch with the session token; a refused to
385
  }
386
  const store={get(k,d){try{const v=localStorage.getItem(k);return v?JSON.parse(v):d}catch{return d}},set(k,v){try{localStorage.setItem(k,JSON.stringify(v))}catch{}}};
387
  let driving=false;
388
- let LESSONS=[], CATS=[], PHRASES=[], cat=store.get("ihki.cat",""), best=store.get("ihki.best2",{}), order=[], pos=0, cur=null;
 
 
389
  let myAudioUrl=null;
390
  const phraseOf=id=>PHRASES.find(p=>p.id===id); // by id: the list skips the phrases that have no tab
391
 
@@ -424,6 +499,14 @@ function count(t){ // "11/70 תורגלו", or the mark of a finished one
424
  if(t.over){ s.textContent=t.gold?"★ מצוין":"✓ הושלם"; return s; }
425
  n.dir="ltr"; n.textContent=`${t.done}/${t.n}`; s.append(n," תורגלו"); return s;
426
  }
 
 
 
 
 
 
 
 
427
  const STEP=108, SWAY=[0,.7,1,.7,0,-.7,-1,-.7]; // the road: the way down from one topic to the next, and how far aside each one stands
428
  let drawnW=0, nowWatch=null;
429
  function drawPath(stay){
@@ -432,7 +515,18 @@ function drawPath(stay){
432
  const road=document.createElementNS(NS,"svg"), stops=[]; // stops: the points the road goes through
433
  road.setAttribute("class","road"); road.setAttribute("aria-hidden","true"); box.innerHTML=""; box.append(road);
434
  let next=true, y=0; // next: the first lesson that is not finished is the one to do now
435
- for(const l of LESSONS){
 
 
 
 
 
 
 
 
 
 
 
436
  const cats=CATS.filter(c=>c.lesson===l.id), parts=cats.map(c=>tally(listed.filter(p=>p.cat===c.id)));
437
  const whole=tally(listed.filter(p=>cats.some(c=>c.id===p.cat))), now=!whole.over&&next;
438
  const todo=Math.max(0,parts.findIndex(t=>!t.over)); // the topic the banner opens
@@ -520,11 +614,11 @@ function setRound(){
520
  box.checked=all; box.disabled=!todo.length;
521
  order=all?topic:todo; pos=0; marks();
522
  }
523
- function startCat(wait=true){ setRound(); show(null,wait); }
524
  // What tells how much was practised, redrawn when a score comes in: the path and the box's text. The count itself is in the list's title.
525
  function marks(){
 
526
  if(!CATS.length) return;
527
- drawPath();
528
  const done=topic.filter(id=>best[id]!=null).length;
529
  $("doneText").textContent=done>=topic.length?"כל המשפטים בנושא תורגלו":"לכלול בתרגול גם משפטים שכבר תורגלו";
530
  }
@@ -557,18 +651,47 @@ $("setBtn").onclick=()=>$("setDlg").showModal();
557
  $("setClose").onclick=()=>$("setDlg").close();
558
  $("listClose").onclick=()=>$("listDlg").close();
559
  $("classClose").onclick=()=>$("classDlg").close();
560
- $("myClass").onclick=async()=>{ // the students of my class, by the nickname the teacher gave or their Google name
561
- const list=$("mates"), note=$("matesNote");
562
- $("classTitle").textContent=$("myClass").textContent; list.innerHTML=""; note.textContent="טוען…"; $("classDlg").showModal();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  try{
564
  const res=await api("/api/classmates"); if(!res.ok) throw 0;
565
- const d=await res.json();
566
- for(const s of d.students){ const li=mark(s.me?"me":"",s.me?`${s.name} (אני)`:s.name,"li"); list.append(li); }
567
- note.textContent=`${d.students.length+d.unnamed} תלמידים`+(d.unnamed?` · ${d.unnamed} עוד לא נכנסו`:"");
 
 
 
 
 
 
 
568
  }catch{ note.textContent="הרשימה לא זמינה כרגע."; }
569
  };
570
- for(const d of document.querySelectorAll("dialog")) d.onclick=e=>{ // a tap outside the box closes it
571
- const r=d.getBoundingClientRect(); if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom) d.close(); };
572
  // Grammar-built phrases (grp set) exist for every pronoun, positive and negative: the two buttons move between them.
573
  function variation(kind){
574
  if(cur.grp==null) return;
@@ -753,7 +876,7 @@ function show(p,wait){ // wait: do not record until the microphone button is p
753
  const c=CATS.find(c=>c.id===cat), mine=topic.includes(cur.id); // a topic may name its items (a verb, a word) and its own task
754
  $("task").textContent=cur.read?"קראו בקול ותרגמו לעברית":mine&&c.task||"איך אומרים את זה בערבית מדוברת?";
755
  $("answerBtn").textContent=cur.read?"הצג תרגום":"הצג תשובה";
756
- $("catName").textContent=c.name; $("lessonName").textContent=(LESSONS.find(l=>l.id===c.lesson)||{}).name||"";
757
  // the number and the bar are the sentence's place in the topic, whatever the round holds
758
  const b=best[cur.id], at=topic.indexOf(cur.id), hintBox=$("doneHint");
759
  $("counter").textContent=ordered?"":`${pos+1} / ${order.length}`;
@@ -1090,10 +1213,20 @@ function sentence(p,under){ // a sentence as the student sees it, and a small
1090
  const text=mark("two",""), name=document.createElement("span");
1091
  setTr(name,p.read?p.tr:p.he); text.append(name,mark("",under,"small")); return text;
1092
  }
1093
- let CLASS=null; // the panel's last answer: {classes, students}
 
 
 
 
 
 
 
 
 
 
1094
  async function loadManage(){
1095
- const state=$("mState"), box=$("mList"), hard=$("mHard");
1096
- state.textContent="טוען…"; box.innerHTML=""; hard.hidden=true; $("mClasses").hidden=true;
1097
  try{
1098
  const res=await api("/api/class"); await ready;
1099
  if(!res.ok){
@@ -1102,120 +1235,188 @@ async function loadManage(){
1102
  }
1103
  CLASS=await res.json();
1104
  }catch{ state.textContent="השרת לא זמין."; return; }
1105
- const filter=$("mFilter"), was=filter.value;
1106
- filter.innerHTML=""; filter.add(new Option("כל הכיתות",""));
1107
- for(const c of CLASS.classes||[]) filter.add(new Option(c.name,c.name));
1108
- filter.value=[...filter.options].some(o=>o.value===was)?was:""; filter.hidden=!(CLASS.classes||[]).length;
1109
- drawClasses(); drawStudents();
1110
  }
1111
- $("mFilter").onchange=()=>drawStudents();
1112
- /* The classes: a class is a name (e.g. אוקטובר 2026) and its students' emails. Once there is one, only the students
1113
- of the classes (and the teachers) can sign in. */
1114
- function drawClasses(){
1115
- const box=$("mClasses"), classes=CLASS.classes||[];
1116
- box.innerHTML=""; box.hidden=false;
1117
- const h=mark("","כיתות","h2"); h.append(mark("",classes.length?"כל כיתה נפתחת על השם ועל כתובות המייל של התלמידים שלה":"","small")); box.append(h);
1118
- if(!classes.length) box.append(mark("note","עוד אין כיתות, ולכן כל חשבון Google יכול להיכנס. מהכיתה הראשונה נכנסים רק התלמידים שבכיתות והמורים.","p"));
1119
- for(const c of [...classes,null]){
1120
- const sec=document.createElement("details"), sum=document.createElement("summary"); sec.className="sec";
1121
- if(c){
1122
- const in_=CLASS.students.filter(s=>s.class===c.name);
1123
- sum.append(mark("",c.name),mark("",`${c.emails.length} תלמידים · ${in_.filter(s=>s.joined).length} נכנסו`,"small"));
1124
- }else sum.append(mark("","+ כיתה חדשה"));
1125
- sec.append(sum,classForm(c)); box.append(sec);
1126
- }
1127
  }
1128
- function classForm(c){ // c: the class to edit, or null for a new one
1129
- const f=document.createElement("form"), name=document.createElement("input"), emails=document.createElement("textarea");
1130
- const msg=mark("msg","","span"), save=mark("btn primary",c?"שמירה":"פתיחת הכיתה","button");
1131
- f.className="cform"; name.required=true; name.maxLength=80; name.placeholder="למשל: אוקטובר 2026"; name.value=c?c.name:"";
1132
- emails.placeholder="dana@gmail.com דנה\nother@gmail.com"; emails.spellcheck=false;
1133
- emails.value=c?c.emails.map(e=>[e,c.nicknames&&c.nicknames[e]].filter(Boolean).join(" ")).join("\n"):"";
1134
- const l1=mark("","שם הכיתה","label"), l2=mark("","התלמידים: בכל שורה כתובת מייל (Google), ואחריה כינוי אם רוצים. בכיתה רואים את הכינוי, או את השם מ-Google","label"), row=mark("row","");
1135
- l1.append(name); l2.append(emails); save.type="submit"; row.append(save);
1136
- if(c){
1137
- const del=mark("btn","מחיקת הכיתה","button"); del.type="button"; row.append(del);
1138
- del.onclick=()=>{ if(confirm(`למחוק את הכיתה ${c.name}? התלמידים שלה לא יוכלו להיכנס, וההקלטות שלהם נשמרות.`)) send("DELETE"); };
1139
  }
1140
- row.append(msg); f.append(l1,l2,row);
1141
- async function send(method,body){
1142
- msg.textContent=""; f.querySelectorAll("button").forEach(b=>b.disabled=true);
1143
- try{
1144
- const res=await api(c?`/api/classes/${c.id}`:"/api/classes",{method,headers:{"Content-Type":"application/json"},body:body&&JSON.stringify(body)});
1145
- if(res.ok) return loadManage();
1146
- msg.textContent=(await res.json().catch(()=>({}))).detail||`השמירה נכשלה (${res.status})`;
1147
- }catch{ msg.textContent="השרת לא זמין."; }
1148
- f.querySelectorAll("button").forEach(b=>b.disabled=false);
 
1149
  }
1150
- f.onsubmit=e=>{ e.preventDefault(); send(c?"PUT":"POST",{name:name.value,emails:emails.value}); };
1151
- return f;
 
 
 
 
 
 
 
 
 
1152
  }
1153
- function drawStudents(){
1154
- const state=$("mState"), box=$("mList"), hard=$("mHard"), only=$("mFilter").value;
1155
- box.innerHTML="";
1156
- const listed=PHRASES.filter(p=>p.listed!==false);
1157
- const tally=(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)}; };
1158
- const all=CLASS.students.filter(s=>!only||s.class===only).map(s=>({...s,...tally(s,listed)})) // the latest to practise first; those who never signed in last
1159
- .sort((a,b)=>(b.last||"").localeCompare(a.last||"")||b.joined-a.joined||a.email.localeCompare(b.email));
1160
- state.textContent=`${all.length} תלמידים · ${all.filter(s=>s.week).length} תרגלו בשבוע האחרון · ${all.reduce((t,s)=>t+s.week,0)} הקלטות בשבוע האחרון`;
1161
- /* The sentences the class finds hardest: the lowest average of the students' best scores. A sentence counts once
1162
- enough students tried it (3, or all of those who practised when they are fewer), so one student's bad try does not lead. */
1163
- const tried=p=>all.filter(s=>s.best[p.id]!=null), need=Math.min(3,all.filter(s=>s.done).length);
1164
- const worst=listed.map(p=>{ const by=tried(p); return {p,k:by.length,avg:mean(by.map(s=>s.best[p.id]))}; })
1165
- .filter(x=>x.k&&x.k>=need).sort((a,b)=>a.avg-b.avg||b.k-a.k).slice(0,5);
1166
- hard.innerHTML=""; hard.hidden=!worst.length;
1167
- if(worst.length){
1168
- const h=mark("","המשפטים הקשים ביותר","h2"); h.append(mark("","הממוצע הנמוך ביותר של הציון הטוב של כל תלמיד","small")); hard.append(h);
1169
- for(const x of worst){
1170
- const row=mark("trow",""), c=CATS.find(c=>c.id===x.p.cat), l=c&&LESSONS.find(l=>l.id===c.lesson);
1171
- row.append(sentence(x.p,c?c.name:""),mark("",l?l.name:"","small"),mark("",`${x.k} תלמידים`,"small"),grade(x.avg)); hard.append(row);
 
 
 
 
 
1172
  }
 
1173
  }
1174
- 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; };
1175
- const part=into=>{ const d=document.createElement("details"); d.className="sec"; d.append(document.createElement("summary")); into.append(d); return d; }; // a closed part of a student's card
1176
- const place=p=>{ const c=CATS.find(c=>c.id===p.cat), l=c&&LESSONS.find(l=>l.id===c.lesson); return [l&&l.name,c&&c.name].filter(Boolean).join(" · "); };
1177
- for(const s of all){
1178
- const det=document.createElement("details"), sum=document.createElement("summary"), who=mark("who",""), bar=mark("progress",""), fill=document.createElement("i");
1179
- det.className="card stu";
1180
- const shown=s.nick||s.name; who.append(mark("",shown||s.email,"b"));
1181
- if(shown){ const m=mark("",s.email,"small"); m.dir="ltr"; who.append(m); }
1182
- if(s.class&&!only) who.append(mark("klass",`כיתת ${s.class}`,"small"));
 
 
 
 
 
 
 
1183
  const when=s.last?new Date(s.last).toLocaleString("he-IL",{day:"numeric",month:"numeric",hour:"2-digit",minute:"2-digit"}):"";
1184
- fill.style.width=`${s.n?s.done/s.n*100:0}%`; bar.append(fill);
1185
- sum.append(who, s.avg!=null?grade(s.avg):mark("none",s.joined?"עוד לא תרגל/ה":"עוד לא נכנס/ה"));
1186
- if(s.avg!=null) sum.lastChild.title="ממוצע הציונים הטובים ביותר";
1187
- sum.append(mark("stats",`תורגלו ${s.done} מתוך ${s.n} משפטים · ${s.recordings} הקלטות, ${s.week} מהן בשבוע האחרון · ${s.days} ימי תרגול`+(when?` · לאחרונה ${when}`:"")),bar);
 
1188
  det.append(sum);
1189
- // the student's best and weakest sentences, by their best score; a sentence is in one list only
1190
- 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();
1191
- for(const [title,list] of [["5 המשפטים הטובים ביותר",top],["5 המשפטים החלשים ביותר",low]]){
1192
- if(!list.length) continue;
1193
- const sec=part(det); sec.firstChild.append(mark("",title));
1194
- for(const p of list){
1195
- const row=mark("trow s3",""), n=s.tries[p.id]||0;
1196
- row.append(sentence(p,place(p)),mark("",n>1?`${n} ניסיונות`:"","small"),grade(s.best[p.id])); sec.append(row);
1197
- }
 
 
 
 
 
 
 
 
 
 
 
 
1198
  }
1199
- for(const l of LESSONS){ // a lesson: how much of it was practised and its grade, then its topics
1200
- const cats=CATS.filter(c=>c.lesson===l.id), t=tally(s,listed.filter(p=>cats.some(c=>c.id===p.cat)));
1201
- const sec=part(det), bar=mark("progress",""), fill=document.createElement("i");
1202
- fill.style.width=`${t.n?t.done/t.n*100:0}%`; bar.append(fill);
1203
- sec.firstChild.append(mark("",l.name),ratio(t),t.avg!=null?grade(t.avg):mark("none","–"),bar);
1204
- for(const c of cats){
1205
- const t=tally(s,listed.filter(p=>p.cat===c.id)), row=mark("trow"+(t.done?"":" untouched"),"");
1206
- row.append(mark("",c.name),mark("",t.tries?`${t.tries} ניסיונות`:"","small"),ratio(t),t.avg!=null?grade(t.avg):mark("none","–")); sec.append(row);
1207
- }
 
 
 
 
 
 
 
 
 
 
1208
  }
1209
- box.append(det);
 
 
1210
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1211
  }
 
 
 
 
 
 
 
 
 
1212
 
 
 
 
 
 
1213
  const ready=fetch("/api/phrases").then(r=>r.json()).then(d=>{
1214
- LESSONS=d.lessons; CATS=d.categories; PHRASES=d.phrases;
1215
- for(const c of CATS) c.name=c.name.replace(/ ← /g,"\u00a0←\u00a0"); // the arrow never ends a line
1216
  for(const v of d.voices||[]) $("voiceId").add(new Option(v.name,v.id));
1217
  const saved=store.get("ihki.voice",""); if((d.voices||[]).some(v=>v.id===saved)) $("voiceId").value=saved;
1218
- if(!CATS.some(c=>c.id===cat)) cat=CATS[0].id;
1219
  startCat(); opened=true;
1220
  }).catch(()=>{ $("prompt").textContent="השרת לא זמין."; });
1221
  </script>
@@ -1224,6 +1425,7 @@ const ready=fetch("/api/phrases").then(r=>r.json()).then(d=>{
1224
  function enter(){
1225
  const splash=$("logo-splash");
1226
  $("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),400);
 
1227
  }
1228
  $("startBtn").onclick=$("practiceBtn").onclick=enter;
1229
  $("manageBtn").onclick=()=>{ enter(); showManage(); };
@@ -1251,7 +1453,11 @@ if(AUTH_URL){
1251
  const res=await api("/api/me").catch(()=>null);
1252
  const ok=!res||res.status!==403; // a server that cannot be reached is not a refusal
1253
  const me=res&&res.ok?await res.json().catch(()=>({})):{}, teacher=!!me.teacher;
1254
- $("myClass").textContent=me.class?`כיתת ${me.class}`:"";
 
 
 
 
1255
  start.hidden=!ok||teacher; roles.hidden=$("toManage").hidden=!teacher; denied.hidden=ok;
1256
  if(ok){ (teacher?$("practiceBtn"):start).focus({preventScroll:true}); loadProgress().then(()=>{ if(PHRASES.length&&$("wrap").inert) startCat(); }); }
1257
  },0);
 
35
  .mates{list-style:none;margin:0;padding:0;columns:2 10em;column-gap:16px}
36
  .mates li{padding:7px 2px;border-bottom:1px solid var(--line);font-size:16px;break-inside:avoid}
37
  .mates li.me{font-weight:700;color:var(--olive)}
38
+ /* a student of several classes chooses the one to practise in: a button per class, the last one chosen marked */
39
+ .picks{display:flex;flex-direction:column;gap:8px}
40
+ .picks button{display:flex;flex-direction:column;gap:4px;padding:12px 14px;border-radius:12px;border:1px solid var(--line);background:var(--bg);color:var(--ink);font:700 18px/1.2 var(--f-body);text-align:start;cursor:pointer}
41
+ .picks button small{font:500 13px/1.3 var(--f-body);color:var(--muted)}
42
+ .picks button.last{border-color:var(--olive);box-shadow:inset 0 0 0 1px var(--olive)}
43
+ .picks button:hover{background:var(--olive-soft)}
44
+ #classSwitch{align-self:flex-start;margin-top:10px}
45
+ #mates h3{margin:14px 0 4px;font:400 19px/1.2 var(--f-display)}
46
+ #mates h3:first-child{margin-top:4px}
47
+ #mates h3 small{margin-inline-start:8px;font:600 13px var(--f-body);color:var(--muted)}
48
  .sub{color:var(--muted);font-size:14px}
49
  /* on the practice card the header names where the student is: the lesson, and under it the topic */
50
  .where{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;font:400 22px/1.15 var(--f-display)}
 
67
  .chapter i::after{content:"";display:block;width:var(--p,0%);height:100%;border-radius:999px;background:var(--on-olive)}
68
  .chapter.gold{background:var(--amber-bg);color:var(--amber);box-shadow:0 4px 0 var(--amber)}
69
  .chapter:active{transform:translateY(3px);box-shadow:0 1px 0 var(--olive-deep)}
70
+ /* a level opens with its name; the lessons still to come stand in one dashed banner, not a button */
71
+ .level{position:absolute;left:0;right:0;margin:0;padding:4px 0;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;background:var(--bg);font:400 24px/1.2 var(--f-display)}
72
+ .level small{font:600 13px/1.2 var(--f-body);color:var(--muted)}
73
+ .chapter.soon{height:52px;padding-block:10px;background:transparent;color:var(--muted);box-shadow:none;outline:2px dashed var(--line);outline-offset:-2px;cursor:default;font-size:18px}
74
  .sat{--c:var(--olive);--t:6px;--s:4px;position:absolute;left:calc(50% + var(--x));top:var(--y);width:72px;height:72px;margin:-36px 0 0 -36px;padding:0;border:0;background:none;color:var(--ink);font:600 15px/1.25 var(--f-body);cursor:pointer}
75
  .ring{display:block;position:relative;width:72px;height:72px;padding:10px 10px 14px;border-radius:50%;background:radial-gradient(closest-side,var(--bg) calc(100% - var(--t)),transparent calc(100% - var(--t) + 1px)),conic-gradient(var(--c) var(--p,0%),var(--track) 0)}
76
  .dot{display:grid;place-items:center;width:100%;height:100%;border-radius:50%;background:var(--surface);color:var(--ink);font:400 22px/1 var(--f-display);box-shadow:0 var(--s) 0 var(--track);transition:transform .08s,box-shadow .08s}
 
160
  .tips .what .ar{font-family:var(--f-ar);font-size:14px;font-weight:400;color:var(--muted)}
161
  #denied{text-align:center;font:600 18px/1.5 var(--f-body);color:#1C241D;padding:0 20px}
162
  #denied button{display:block;margin:12px auto 0;font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
163
+ /* The teacher's panel (its parts are styled further down: the class tabs, the figures, the lessons, the students). */
164
  body:not(.manage) #manage,body.manage #wrap>header,body.manage #path,body.manage #toNow,body.manage #practice,body:not(.home) #toManage{display:none}
165
  .mtitle{font:400 24px/1 var(--f-display);margin:0}
166
+ .hard>.note{margin:0;padding:4px 16px 8px;color:var(--muted);font-size:14px}
 
 
 
 
 
 
 
 
 
167
  /* inside a student, each part (the best sentences, the weakest, every lesson) opens and closes on its heading */
168
+ .sec>summary{cursor:pointer;list-style:none;display:grid;grid-template-columns:1fr auto 3em;gap:4px 10px;align-items:center;padding:8px 16px;border-top:1px solid var(--line);font:700 15px/1.4 var(--f-body)}
169
  .sec>summary>:first-child::before{content:"◂";display:inline-block;margin-inline-end:6px;color:var(--muted);transition:transform .12s}
170
  .sec[open]>summary>:first-child::before{transform:rotate(-90deg)}
171
  .sec>summary small{font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums}
 
180
  .hard h2 small{display:block;margin-top:2px;font:600 13px/1.3 var(--f-body);color:var(--muted)}
181
  .hard .trow{grid-template-columns:1fr auto auto 3em;border-top:1px solid var(--line);padding-block:7px}
182
  .trow .two{display:flex;flex-direction:column;font-weight:600}
183
+ .trow.s3{grid-template-columns:1fr auto 3em}
184
+ /* the panel: a tab per class; under it the class's figures, its lessons with the sentences it finds hard, its students */
185
+ .tabs{display:flex;gap:8px;overflow-x:auto;padding-bottom:2px;scrollbar-width:thin}
186
+ .tab{flex:none;font:700 15px/1 var(--f-body);padding:9px 14px;border-radius:999px;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer;white-space:nowrap}
187
+ .tab small{font-weight:600;color:var(--muted);margin-inline-start:6px;font-variant-numeric:tabular-nums}
188
+ .tab[aria-selected=true]{background:var(--olive);border-color:var(--olive);color:var(--on-olive)}
189
+ .tab[aria-selected=true] small{color:inherit;opacity:.8}
190
+ .tab.add{border-style:dashed;color:var(--olive);background:transparent}
191
+ .chead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
192
+ .chead h2{flex:1;margin:0;font:400 24px/1.1 var(--f-display);overflow-wrap:anywhere}
193
+ .chead p{flex:0 0 100%;margin:0;color:var(--muted);font-size:14px}
194
+ .tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
195
+ .tile{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border-radius:10px;background:var(--bg)}
196
+ .tile b{font:400 24px/1.1 var(--f-display);font-variant-numeric:tabular-nums}
197
+ .tile small{color:var(--muted);font-size:13px;line-height:1.25}
198
+ @media (max-width:520px){.tiles{grid-template-columns:repeat(2,1fr)}}
199
+ .lsum>summary{grid-template-columns:auto 1fr 3em}
200
+ .lsum>summary>:first-child{white-space:nowrap}
201
+ .lsum>summary small{text-align:end}
202
+ .lsum .progress{grid-column:1/-1;margin:2px 0 0}
203
+ .flag{color:var(--sumac);font-weight:700;white-space:nowrap}
204
+ .lsum .note{margin:0;padding:6px 16px;color:var(--muted);font-size:14px}
205
+ .trow .tries{text-align:end}
206
+ /* the students: a row each, a column per lesson (its sentences practised, and a bar), the grade; a row opens on its details */
207
+ .rhead,.rrow>summary{display:grid;grid-template-columns:var(--cols);gap:10px;align-items:center;padding:8px 16px}
208
+ .rhead{padding-block:0 6px;color:var(--muted);font:700 12px/1.2 var(--f-body)}
209
+ .rhead span:not(:first-child){text-align:center}
210
+ .rhead span:last-child{text-align:end}
211
+ .rrow{border-top:1px solid var(--line)}
212
+ .rrow>summary{cursor:pointer;list-style:none}
213
+ .rrow>summary::-webkit-details-marker{display:none}
214
+ .rrow>summary:hover{background:var(--bg)}
215
+ .rrow[open]>summary{background:var(--olive-soft)}
216
+ .rrow .who{display:flex;flex-direction:column;min-width:0}
217
+ .rrow .who b{align-self:flex-start;max-width:100%;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
218
+ .rrow .who small{color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
219
+ .cell{display:flex;flex-direction:column;align-items:stretch;gap:3px;font:600 12px/1 var(--f-body);color:var(--muted);text-align:center;font-variant-numeric:tabular-nums}
220
+ .cell .progress{height:5px;margin:0}
221
+ .rrow .pill,.rrow .none{justify-self:end}
222
+ .rrow .none{color:var(--muted);font-size:12px;text-align:end}
223
+ .rdetail{padding:0 0 8px}
224
+ .rdetail .stats{margin:0;padding:4px 16px 8px;color:var(--muted);font-size:14px}
225
+ .cform{display:flex;flex-direction:column;gap:10px}
226
+ .cform label{display:flex;flex-direction:column;gap:4px;font:700 14px/1.3 var(--f-body)}
227
+ .cform label small{font-weight:500;color:var(--muted)}
228
+ .cform input,.cform textarea{font:500 15px/1.5 var(--f-body);padding:8px 10px;border-radius:8px;border:1px solid var(--line);background:var(--bg);color:var(--ink)}
229
+ .cform textarea{min-height:9em;resize:vertical;direction:ltr;text-align:left}
230
  .cform .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
231
+ .cform .row .sep{flex:1}
232
+ .cform .msg{flex:0 0 100%;color:var(--sumac);font-size:14px}
233
+ .cform .msg:empty{display:none}
234
+ .lpick{margin:0;padding:0;border:0;min-width:0}
235
+ .lpick legend{padding:0;margin-bottom:6px;font:700 14px/1.3 var(--f-body)}
236
+ .lpick legend small{display:block;font-weight:500;color:var(--muted)}
237
+ #editLessons{display:flex;flex-direction:column;gap:10px}
238
+ .lvl{display:flex;flex-direction:column;gap:6px}
239
+ #editLessons .lvlhead{flex-direction:row;align-items:center;gap:8px;font:700 15px/1.2 var(--f-body);color:var(--ink);cursor:pointer}
240
+ .chips{display:flex;flex-wrap:wrap;gap:6px}
241
+ #editLessons .chips label{flex-direction:row;align-items:center;gap:5px;padding:5px 10px;border:1px solid var(--line);border-radius:999px;font:600 14px/1 var(--f-body);color:var(--ink);cursor:pointer;font-variant-numeric:tabular-nums}
242
+ #editLessons .chips label.soon{border-style:dashed;color:var(--muted)}
243
+ #editLessons .chips label:has(:checked){border-color:var(--olive);background:var(--olive-soft);color:var(--olive)}
244
+ #editLessons input{width:18px;height:18px;margin:0;accent-color:var(--olive)}
245
+ .btn.danger{color:var(--sumac);border-color:currentColor}
246
  #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}
247
  .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)}
248
  body.driving .drive{background:var(--sumac);border-color:var(--sumac);color:#fff}
 
333
  <div id="logo-splash"><img src="static/logo-intro.svg" alt="אהלא וסהלא, בית הספר ללימוד השפה הערבית" width="489" height="225"><button id="startBtn">יאללה נבלש</button><div id="roles" hidden><button id="practiceBtn">תרגול</button><button id="manageBtn">ניהול</button></div><button id="loginBtn" hidden>כניסה עם Google</button><div id="denied" hidden>החשבון הזה אינו באף כיתה. בקשו מהמורה להוסיף אותו.<button id="switchBtn">כניסה עם חשבון אחר</button></div></div>
334
  <div class="wrap" id="wrap" inert>
335
  <header>
336
+ <div class="brand">אהלא וסהלא<button id="myClass" title="התלמידים בכיתות שלי"></button></div>
337
  <div class="where"><small id="lessonName"></small><span id="catName"></span></div>
338
  <div class="sub maphint">בחרו נושא במסלול ותרגלו אותו בקול</div>
339
  <button class="drive" id="toManage" hidden>ניהול</button>
 
395
  </div>
396
 
397
  <section class="wrap" id="manage" aria-labelledby="mTitle">
398
+ <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>
399
  <div class="sub" id="mState" aria-live="polite"></div>
400
+ <div class="tabs" id="mTabs" role="tablist" aria-label="כיתות" hidden></div>
401
+ <div class="card" id="mHead" hidden></div>
402
+ <div class="card hard" id="mLessons" hidden></div>
403
+ <div class="card hard roster" id="mRoster" hidden></div>
404
  </section>
405
  </div>
406
  <dialog id="listDlg" aria-labelledby="listTitle">
 
408
  <label class="donebox"><input type="checkbox" id="showDone"><span id="doneText"></span></label>
409
  <ol class="plist" id="plist"></ol>
410
  </dialog>
411
+ <dialog id="editDlg" aria-labelledby="editTitle">
412
+ <div class="dlg-head"><h2 id="editTitle"></h2><button class="btn" id="editClose">סגירה</button></div>
413
+ <form class="cform" id="editForm">
414
+ <label>שם הכיתה<input id="editName" required maxlength="80" placeholder="למשל: אוקטובר 2026" autocomplete="off"></label>
415
+ <fieldset class="lpick"><legend>השיעורים<small>התלמידים של הכיתה רואים במסלול רק אותם. שיעור מקווקו עוד לא מוכן: הוא ייפתח לכיתה כשיהיה.</small></legend><div id="editLessons"></div></fieldset>
416
+ <label>התלמידים<small>שורה לכל תלמיד: כתובת ה-Gmail, ואחריה כינוי אם רוצים. בכיתה רואים את הכינוי, או את השם מ-Google. תלמיד יכול להיות בכמה ��יתות.</small>
417
+ <textarea id="editEmails" spellcheck="false" placeholder="dana@gmail.com דנה&#10;avi@gmail.com"></textarea></label>
418
+ <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>
419
+ </form>
420
+ </dialog>
421
  <dialog id="classDlg" aria-labelledby="classTitle">
422
  <div class="dlg-head"><h2 id="classTitle"></h2><button class="btn" id="classClose">סגירה</button></div>
423
+ <div id="mates"></div>
424
  <p class="sub" id="matesNote"></p>
425
+ <button class="btn" id="classSwitch" hidden>מעבר לכיתה אחרת</button>
426
+ </dialog>
427
+ <dialog id="pickDlg" aria-labelledby="pickTitle">
428
+ <div class="dlg-head"><h2 id="pickTitle">באיזו כיתה מתרגלים עכשיו?<small>אפשר להחליף אחר כך, בלחיצה על שם הכיתה</small></h2></div>
429
+ <div class="picks" id="picks"></div>
430
  </dialog>
431
  <dialog id="driveDlg" aria-labelledby="driveTitle">
432
  <div class="dlg-head"><h2 id="driveTitle">מצב נסיעה</h2></div>
 
458
  }
459
  const store={get(k,d){try{const v=localStorage.getItem(k);return v?JSON.parse(v):d}catch{return d}},set(k,v){try{localStorage.setItem(k,JSON.stringify(v))}catch{}}};
460
  let driving=false;
461
+ /* LESSONS and CATS are the student's: the lessons of their classes (all of them for a teacher, or before the first class) */
462
+ let MY_CLASSES=[], MY_CLASS=null; // a student's classes ({name, lessons}), and the one they practise in now
463
+ let ALL_LEVELS=[], ALL_LESSONS=[], ALL_CATS=[], MY_LESSONS=null, LESSONS=[], CATS=[], PHRASES=[], cat=store.get("ihki.cat",""), best=store.get("ihki.best2",{}), order=[], pos=0, cur=null;
464
  let myAudioUrl=null;
465
  const phraseOf=id=>PHRASES.find(p=>p.id===id); // by id: the list skips the phrases that have no tab
466
 
 
499
  if(t.over){ s.textContent=t.gold?"★ מצוין":"✓ הושלם"; return s; }
500
  n.dir="ltr"; n.textContent=`${t.done}/${t.n}`; s.append(n," תורגלו"); return s;
501
  }
502
+ const levelOf=l=>ALL_LEVELS.find(v=>v.id===l.level)||{name:""};
503
+ const lessonName=l=>l?(ALL_LEVELS.length>1?levelOf(l).name+" · ":"")+l.name:"";
504
+ function lessonsWord(ls){ // lessons of one level by their numbers: "שיעור 3", "שיעורים 3–10", "שיעורים 1–2, 5"
505
+ const runs=[]; for(const l of ls){ const r=runs[runs.length-1]; if(r&&l.n===r[1]+1) r[1]=l.n; else runs.push([l.n,l.n]); }
506
+ // each run isolated left to right, so Hebrew text around it does not turn "1–10" into "10–1"
507
+ return (ls.length>1?"שיעורים ":"שיעור ")+runs.map(([a,b])=>a===b?a:`\u2066${a}–${b}\u2069`).join(", ");
508
+ }
509
+ const byLevel=ls=>ALL_LEVELS.map(level=>({level,lessons:ls.filter(l=>l.level===level.id)})).filter(g=>g.lessons.length);
510
  const STEP=108, SWAY=[0,.7,1,.7,0,-.7,-1,-.7]; // the road: the way down from one topic to the next, and how far aside each one stands
511
  let drawnW=0, nowWatch=null;
512
  function drawPath(stay){
 
515
  const road=document.createElementNS(NS,"svg"), stops=[]; // stops: the points the road goes through
516
  road.setAttribute("class","road"); road.setAttribute("aria-hidden","true"); box.innerHTML=""; box.append(road);
517
  let next=true, y=0; // next: the first lesson that is not finished is the one to do now
518
+ let level=null;
519
+ for(let k=0;k<LESSONS.length;k++){
520
+ const l=LESSONS[k];
521
+ if(l.level!==level){ // a level opens with its name and how many of its lessons are ready
522
+ level=l.level; const of=LESSONS.filter(x=>x.level===level), h=mark("level",levelOf(l).name,"h2");
523
+ h.style.top=y+"px"; h.append(mark("",`${of.filter(x=>!x.soon).length} מתוך ${of.length} שיעורים מוכנים`,"small")); box.append(h); y+=60;
524
+ }
525
+ if(l.soon){ // the lessons still to come, a run of them in one banner
526
+ const run=[l]; while(LESSONS[k+1]&&LESSONS[k+1].soon&&LESSONS[k+1].level===level) run.push(LESSONS[++k]);
527
+ const b=mark("chapter soon",""); b.style.top=y+"px"; b.append(mark("",lessonsWord(run)),mark("","בקרוב","small")); box.append(b);
528
+ y+=52+28; continue;
529
+ }
530
  const cats=CATS.filter(c=>c.lesson===l.id), parts=cats.map(c=>tally(listed.filter(p=>p.cat===c.id)));
531
  const whole=tally(listed.filter(p=>cats.some(c=>c.id===p.cat))), now=!whole.over&&next;
532
  const todo=Math.max(0,parts.findIndex(t=>!t.over)); // the topic the banner opens
 
614
  box.checked=all; box.disabled=!todo.length;
615
  order=all?topic:todo; pos=0; marks();
616
  }
617
+ function startCat(wait=true){ if(!CATS.length) return marks(); setRound(); show(null,wait); }
618
  // What tells how much was practised, redrawn when a score comes in: the path and the box's text. The count itself is in the list's title.
619
  function marks(){
620
+ drawPath(); // with no topic yet, the path still shows what is to come
621
  if(!CATS.length) return;
 
622
  const done=topic.filter(id=>best[id]!=null).length;
623
  $("doneText").textContent=done>=topic.length?"כל המשפטים בנושא תורגלו":"לכלול בתרגול גם משפטים שכבר תורגלו";
624
  }
 
651
  $("setClose").onclick=()=>$("setDlg").close();
652
  $("listClose").onclick=()=>$("listDlg").close();
653
  $("classClose").onclick=()=>$("classDlg").close();
654
+ $("classSwitch").onclick=()=>{ $("classDlg").close(); askClass(); };
655
+ /* A student of several classes practises in one at a time: its lessons are the path. They are asked after the opening
656
+ screen, and again from the class's name in the header. */
657
+ function useClass(c){
658
+ MY_CLASS=c; store.set("ihki.class",c.name);
659
+ $("myClass").textContent=`כיתת ${c.name}`; $("classSwitch").hidden=MY_CLASSES.length<2;
660
+ MY_LESSONS=c.lessons;
661
+ if(ALL_LESSONS.length){ useLessons(MY_LESSONS); if(!home) showPath(); startCat(); }
662
+ }
663
+ async function askClass(){
664
+ await ready;
665
+ const box=$("picks"), last=MY_CLASS?MY_CLASS.name:store.get("ihki.class","");
666
+ box.innerHTML="";
667
+ for(const c of MY_CLASSES){
668
+ const b=mark(c.name===last?"last":"",c.name,"button"), ls=ALL_LESSONS.filter(l=>c.lessons.includes(l.id));
669
+ b.append(mark("",byLevel(ls).map(g=>`${g.level.name}: ${lessonsWord(g.lessons)}`).join(" · "),"small"));
670
+ b.onclick=()=>{ $("pickDlg").close(); useClass(c); };
671
+ box.append(b);
672
+ }
673
+ $("pickDlg").showModal(); (box.querySelector(".last")||box.firstChild).focus();
674
+ }
675
+ $("pickDlg").oncancel=e=>{ if(!MY_CLASS) e.preventDefault(); }; // Escape closes it only when a class was already chosen
676
+ $("myClass").onclick=async()=>{ // the students of my classes, by the nickname the teacher gave or their Google name
677
+ const box=$("mates"), note=$("matesNote");
678
+ $("classTitle").textContent=$("myClass").textContent; box.innerHTML=""; note.textContent="טוען…"; $("classDlg").showModal();
679
  try{
680
  const res=await api("/api/classmates"); if(!res.ok) throw 0;
681
+ const d=await res.json(), shown=MY_CLASS?d.classes.filter(c=>c.name===MY_CLASS.name):d.classes, several=shown.length>1;
682
+ if(several) $("classTitle").textContent="הכיתות שלי";
683
+ for(const c of shown){
684
+ const list=mark("mates","","ul"), count=`${c.students.length+c.unnamed} תלמידים`+(c.unnamed?` · ${c.unnamed} עוד לא נכנסו`:"");
685
+ if(several){ const h=mark("","כיתת "+c.name,"h3"); h.append(mark("",count,"small")); box.append(h); }
686
+ for(const s of c.students) list.append(mark(s.me?"me":"",s.me?`${s.name} (אני)`:s.name,"li"));
687
+ box.append(list);
688
+ if(!several) note.textContent=count;
689
+ }
690
+ if(several) note.textContent="";
691
  }catch{ note.textContent="הרשימה לא זמינה כרגע."; }
692
  };
693
+ 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)
694
+ 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(); };
695
  // Grammar-built phrases (grp set) exist for every pronoun, positive and negative: the two buttons move between them.
696
  function variation(kind){
697
  if(cur.grp==null) return;
 
876
  const c=CATS.find(c=>c.id===cat), mine=topic.includes(cur.id); // a topic may name its items (a verb, a word) and its own task
877
  $("task").textContent=cur.read?"קראו בקול ותרגמו לעברית":mine&&c.task||"איך אומרים את זה בערבית מדוברת?";
878
  $("answerBtn").textContent=cur.read?"הצג תרגום":"הצג תשובה";
879
+ $("catName").textContent=c.name; $("lessonName").textContent=lessonName(LESSONS.find(l=>l.id===c.lesson));
880
  // the number and the bar are the sentence's place in the topic, whatever the round holds
881
  const b=best[cur.id], at=topic.indexOf(cur.id), hintBox=$("doneHint");
882
  $("counter").textContent=ordered?"":`${pos+1} / ${order.length}`;
 
1213
  const text=mark("two",""), name=document.createElement("span");
1214
  setTr(name,p.read?p.tr:p.he); text.append(name,mark("",under,"small")); return text;
1215
  }
1216
+ /* The panel shows one class at a time (a tab each). Before the first class it shows every account that signed in. */
1217
+ let CLASS=null, shownClass=store.get("ihki.mclass",""); // the panel's last answer {classes, students}; the class on show
1218
+ let CLASS_LESSONS=[], CLASS_SOON=[]; // the ready lessons of the class on show, and those still to come (every lesson before the first class)
1219
+ const HARD=70; // a sentence whose class average (of each student's best score) is under this is flagged as hard
1220
+ const listedPhrases=()=>PHRASES.filter(p=>p.listed!==false);
1221
+ 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)); };
1222
+ 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)}; };
1223
+ 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; };
1224
+ const bar=frac=>{ const b=mark("progress",""), f=document.createElement("i"); f.style.width=`${Math.round(frac*100)}%`; b.append(f); return b; };
1225
+ 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(" · "); };
1226
+ const part=into=>{ const d=document.createElement("details"); d.className="sec"; d.append(document.createElement("summary")); into.append(d); return d; }; // a closed part of a card
1227
  async function loadManage(){
1228
+ const state=$("mState");
1229
+ state.textContent="טוען…";
1230
  try{
1231
  const res=await api("/api/class"); await ready;
1232
  if(!res.ok){
 
1235
  }
1236
  CLASS=await res.json();
1237
  }catch{ state.textContent="השרת לא זמין."; return; }
1238
+ state.textContent=""; drawManage();
 
 
 
 
1239
  }
1240
+ function drawManage(){
1241
+ const classes=CLASS.classes||[];
1242
+ if(!classes.some(c=>c.name===shownClass)) shownClass=classes.length?classes[0].name:"";
1243
+ const klass=classes.find(c=>c.name===shownClass)||null;
1244
+ const mine=ALL_LESSONS.filter(l=>!klass||klass.lessons.includes(l.id));
1245
+ CLASS_LESSONS=mine.filter(l=>!l.soon); CLASS_SOON=mine.filter(l=>l.soon);
1246
+ const listed=CLASS_LESSONS.flatMap(l=>lessonPhrases(l,listedPhrases())); // the class's sentences: its figures count these only
1247
+ // a student of the class, with its nickname in it; the totals beside their own fields (`tries` stays the per-sentence one)
1248
+ const students=CLASS.students.filter(s=>!klass||klass.name in s.classes).map(s=>{ const t=stuTally(s,listed);
1249
+ return {...s,nick:klass?s.classes[klass.name]:Object.values(s.classes).find(Boolean)||"",done:t.done,n:t.n,avg:t.avg}; });
1250
+ drawTabs(classes); drawHead(klass,students); drawLessons(students,listed); drawRoster(students,listed);
 
 
 
 
 
1251
  }
1252
+ function drawTabs(classes){
1253
+ const tabs=$("mTabs"); tabs.innerHTML=""; tabs.hidden=!classes.length;
1254
+ for(const c of classes){
1255
+ const t=mark("tab","","button"), name=document.createElement("bdi"); t.setAttribute("role","tab");
1256
+ 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);
1257
+ t.append(mark("",c.emails.length,"small"));
1258
+ t.onclick=()=>{ shownClass=c.name; store.set("ihki.mclass",c.name); drawManage(); };
1259
+ tabs.append(t);
 
 
 
1260
  }
1261
+ const add=mark("tab add","+ כיתה חדשה","button"); add.onclick=()=>editClass(null); tabs.append(add);
1262
+ }
1263
+ function drawHead(klass,students){ // the class's name, its edit button and its figures; before the first class, how to open one
1264
+ const head=$("mHead"); head.innerHTML=""; head.hidden=false;
1265
+ const top=mark("chead","");
1266
+ if(!klass){
1267
+ top.append(mark("","עוד אין כיתות","h2"));
1268
+ const open=mark("btn primary","פתיחת כיתה","button"); open.onclick=()=>editClass(null); top.append(open);
1269
+ top.append(mark("","עד שנפתחת כיתה, כל חשבון Google יכול להיכנס, ולמטה מופיע כל מי שנכנס. מהכיתה הראשונה נכנסים רק התלמידים שבכיתות והמורים.","p"));
1270
+ head.append(top); return;
1271
  }
1272
+ const edit=mark("btn","עריכת הכיתה","button"); edit.onclick=()=>editClass(klass);
1273
+ const what=byLevel([...CLASS_LESSONS,...CLASS_SOON].sort((a,b)=>ALL_LESSONS.indexOf(a)-ALL_LESSONS.indexOf(b))) // "רמה 1: שיעורים 1–10 (2 מוכנים)"
1274
+ .map(g=>`${g.level.name}: ${lessonsWord(g.lessons)} (${g.lessons.filter(l=>!l.soon).length} מוכנים)`).join(" · ");
1275
+ top.append(mark("",klass.name,"h2"),edit,mark("",what,"p")); head.append(top);
1276
+ 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); };
1277
+ const avg=mean(students.filter(s=>s.avg!=null).map(s=>s.avg));
1278
+ tile(`${students.filter(s=>s.joined).length}/${students.length}`,"נכנסו לאפליקציה");
1279
+ tile(students.filter(s=>s.week).length,"תרגלו בשבוע האחרון");
1280
+ tile(students.reduce((t,s)=>t+s.week,0),"הקלטות בשבוע האחרון");
1281
+ tile(avg??"–","ציון ממוצע");
1282
+ head.append(tiles);
1283
  }
1284
+ /* A lesson of the class: how much of it the students practised (on average), their grade, and the sentences the class
1285
+ finds hard: the class average of the students' best scores is under HARD. A sentence counts once enough students tried
1286
+ it (2, or 1 when only one practised the lesson), so one student's bad try does not flag it. */
1287
+ function drawLessons(students,listed){
1288
+ const box=$("mLessons"); box.innerHTML=""; box.hidden=!students.length&&!CLASS_SOON.length;
1289
+ const h=mark("","שיעורים","h2"); h.append(mark("",`⚑ משפט קשה: ממוצע הכיתה בו נמוך מ-${HARD}`,"small")); box.append(h);
1290
+ for(const l of CLASS_LESSONS){
1291
+ const list=lessonPhrases(l,listed), per=students.map(s=>stuTally(s,list)), active=per.filter(t=>t.done).length;
1292
+ const need=Math.min(2,active);
1293
+ const hard=list.map(p=>{ const by=students.filter(s=>s.best[p.id]!=null);
1294
+ return {p,k:by.length,avg:mean(by.map(s=>s.best[p.id])),tries:by.length?by.reduce((t,s)=>t+(s.tries[p.id]||0),0)/by.length:0}; })
1295
+ .filter(x=>x.k&&x.k>=need&&x.avg<HARD).sort((a,b)=>a.avg-b.avg||b.k-a.k);
1296
+ const sec=part(box), sum=sec.firstChild, avg=mean(per.filter(t=>t.avg!=null).map(t=>t.avg));
1297
+ const done=per.length?per.reduce((t,x)=>t+(x.n?x.done/x.n:0),0)/per.length:0;
1298
+ sec.classList.add("lsum"); sec.open=hard.length>0;
1299
+ const info=mark("",`${Math.round(done*100)}% מהמשפטים · ${active} תלמידים תרגלו`,"small");
1300
+ if(hard.length) info.append(" · ",mark("flag",`⚑ ${hard.length} קשים`));
1301
+ sum.append(mark("",lessonName(l)),info,avg!=null?grade(avg):mark("none","–"),bar(done));
1302
+ if(!hard.length){ sec.append(mark("note",active?"אין משפטים קשים בשיעור הזה.":"עוד אף תלמיד לא תרגל את השיעור הזה.","p")); continue; }
1303
+ for(const x of hard.slice(0,10)){
1304
+ const row=mark("trow","");
1305
+ row.append(sentence(x.p,(ALL_CATS.find(c=>c.id===x.p.cat)||{}).name||""),mark("",`${x.k}/${students.length} תלמידים`,"small"),
1306
+ mark("tries",`${x.tries.toFixed(1)} ניסיונות`,"small"),grade(x.avg));
1307
+ sec.append(row);
1308
  }
1309
+ if(hard.length>10) sec.append(mark("note",`ועוד ${hard.length-10}.`,"p"));
1310
  }
1311
+ if(CLASS_SOON.length) box.append(mark("note","בקרוב: "+byLevel(CLASS_SOON).map(g=>`${g.level.name}, ${lessonsWord(g.lessons)}`).join(" · "),"p"));
1312
+ }
1313
+ /* The students: the latest to practise first, those who never signed in last. A row opens on the student's numbers, their
1314
+ best and weakest sentences, and each lesson by topic. */
1315
+ function drawRoster(students,listed){
1316
+ // a column per lesson, or per level once the class has more ready lessons than fit
1317
+ const cols=CLASS_LESSONS.length<=4?CLASS_LESSONS.map(l=>({name:l.name,title:lessonName(l),list:lessonPhrases(l,listed)}))
1318
+ :byLevel(CLASS_LESSONS).map(g=>({name:g.level.name,title:g.level.name,list:g.lessons.flatMap(l=>lessonPhrases(l,listed))}));
1319
+ 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
1320
+ const h=mark("","תלמידים","h2"); h.append(mark("","לחיצה על תלמיד פותחת את הפירוט","small")); box.append(h);
1321
+ if(!students.length){ box.append(mark("note","אין עדיין תלמידים בכיתה הזו. הוסיפו אותם ב\"עריכת הכיתה\".","p")); return; }
1322
+ const head=mark("rhead",""); head.append(mark("","תלמיד/ה"),...cols.map(c=>mark("",c.name)),mark("","ציון")); box.append(head);
1323
+ students.sort((a,b)=>(b.last||"").localeCompare(a.last||"")||b.joined-a.joined||a.email.localeCompare(b.email));
1324
+ for(const s of students){
1325
+ const det=document.createElement("details"), sum=document.createElement("summary"), who=mark("who","");
1326
+ det.className="rrow";
1327
  const when=s.last?new Date(s.last).toLocaleString("he-IL",{day:"numeric",month:"numeric",hour:"2-digit",minute:"2-digit"}):"";
1328
+ const shown=mark("",s.nick||s.name||s.email,"b"); shown.dir="auto"; // an address is cut at its end
1329
+ who.append(shown,mark("",!s.joined?"עוד לא נכנס/ה":when?`לאחרונה ${when}`:"עוד לא תרגל/ה","small"));
1330
+ who.title=s.email; sum.append(who);
1331
+ 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); }
1332
+ sum.append(s.avg!=null?grade(s.avg):mark("none","–"));
1333
  det.append(sum);
1334
+ det.ontoggle=()=>{ if(det.open&&!det.querySelector(".rdetail")) det.append(studentDetail(s,listed)); }; // built when first opened
1335
+ box.append(det);
1336
+ }
1337
+ }
1338
+ function studentDetail(s,listed){
1339
+ const out=mark("rdetail",""), mail=document.createElement("bdi"); mail.dir="ltr"; mail.textContent=s.email;
1340
+ const stats=mark("stats","","p"); stats.append(mail,` · תורגלו ${s.done} מתוך ${s.n} משפטים · ${s.recordings} הקלטות, ${s.week} מהן בשבוע האחרון · ${s.days} ימי תרגול`);
1341
+ out.append(stats);
1342
+ // the best and weakest sentences, by the best score; a sentence is in one list only
1343
+ 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();
1344
+ for(const [title,list] of [["5 המשפטים הטובים ביותר",top],["5 המשפטים החלשים ביותר",low]]){
1345
+ if(!list.length) continue;
1346
+ const sec=part(out); sec.firstChild.append(mark("",title));
1347
+ 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); }
1348
+ }
1349
+ for(const l of CLASS_LESSONS){ // a lesson's topics
1350
+ const cats=ALL_CATS.filter(c=>c.lesson===l.id), t=stuTally(s,lessonPhrases(l,listed)), sec=part(out);
1351
+ sec.firstChild.append(mark("",`${lessonName(l)} לפי נושאים`),ratio(t),t.avg!=null?grade(t.avg):mark("none","–"));
1352
+ for(const c of cats){
1353
+ const t=stuTally(s,listed.filter(p=>p.cat===c.id)), row=mark("trow"+(t.done?"":" untouched"),"");
1354
+ row.append(mark("",c.name),mark("",t.tries?`${t.tries} ניסיונות`:"","small"),ratio(t),t.avg!=null?grade(t.avg):mark("none","–")); sec.append(row);
1355
  }
1356
+ }
1357
+ return out;
1358
+ }
1359
+ /* A class is opened, edited and deleted in one dialog: a name, and a line per student (email, then an optional nickname). */
1360
+ let editing=null;
1361
+ function editClass(c){
1362
+ editing=c;
1363
+ $("editTitle").textContent=c?`עריכת ${c.name}`:"כיתה חדשה";
1364
+ $("editName").value=c?c.name:"";
1365
+ // the lessons by level: a level's box ticks all of it, its chips one lesson each; a new class starts with the first level
1366
+ const pick=$("editLessons"), chosen=c?c.lessons:ALL_LESSONS.filter(l=>l.level===(ALL_LEVELS[0]||{}).id).map(l=>l.id);
1367
+ pick.innerHTML="";
1368
+ for(const v of ALL_LEVELS){
1369
+ const row=mark("lvl",""), head=mark("lvlhead","","label"), all=document.createElement("input"), chips=mark("chips",""), boxes=[];
1370
+ all.type="checkbox"; head.append(all,v.name); row.append(head,chips); pick.append(row);
1371
+ for(const l of ALL_LESSONS.filter(l=>l.level===v.id)){
1372
+ const box=document.createElement("input"), lab=mark(l.soon?"soon":"","","label");
1373
+ box.type="checkbox"; box.value=l.id; box.checked=chosen.includes(l.id); boxes.push(box);
1374
+ lab.append(box,String(l.n)); lab.title=l.soon?`${l.name}: עוד לא מוכן, ייפתח לכיתה כשיהיה`:l.name; chips.append(lab);
1375
  }
1376
+ const sync=()=>{ const n=boxes.filter(b=>b.checked).length; all.checked=n===boxes.length; all.indeterminate=n>0&&n<boxes.length; };
1377
+ all.onchange=()=>{ boxes.forEach(b=>b.checked=all.checked); sync(); };
1378
+ boxes.forEach(b=>b.onchange=sync); sync();
1379
  }
1380
+ $("editEmails").value=c?c.emails.map(e=>[e,c.nicknames&&c.nicknames[e]].filter(Boolean).join(" ")).join("\n"):"";
1381
+ $("editSave").textContent=c?"שמירה":"פתיחת הכיתה"; $("editDelete").hidden=!c; $("editMsg").textContent="";
1382
+ countStudents(); $("editDlg").showModal(); (c?$("editEmails"):$("editName")).focus();
1383
+ }
1384
+ function countStudents(){ const n=($("editEmails").value.match(/[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+/g)||[]).length; $("editCount").textContent=n?`${n} תלמידים`:""; }
1385
+ $("editEmails").oninput=countStudents;
1386
+ $("editClose").onclick=()=>$("editDlg").close();
1387
+ async function sendClass(method,body){
1388
+ const msg=$("editMsg"), buttons=$("editForm").querySelectorAll("button");
1389
+ msg.textContent=""; buttons.forEach(b=>b.disabled=true);
1390
+ try{
1391
+ const res=await api(editing?`/api/classes/${editing.id}`:"/api/classes",{method,headers:{"Content-Type":"application/json"},body:body&&JSON.stringify(body)});
1392
+ if(res.ok){
1393
+ shownClass=method==="DELETE"?"":body.name.trim(); store.set("ihki.mclass",shownClass);
1394
+ $("editDlg").close(); loadManage();
1395
+ }else msg.textContent=(await res.json().catch(()=>({}))).detail||`השמירה נכשלה (${res.status})`;
1396
+ }catch{ msg.textContent="השרת לא זמין."; }
1397
+ buttons.forEach(b=>b.disabled=false);
1398
  }
1399
+ $("editForm").onsubmit=e=>{
1400
+ e.preventDefault();
1401
+ const lessons=[...$("editLessons").querySelectorAll(".chips :checked")].map(b=>b.value);
1402
+ if(!lessons.length){ $("editMsg").textContent="בחרו לפחות שיעור אחד."; return; }
1403
+ sendClass(editing?"PUT":"POST",{name:$("editName").value,emails:$("editEmails").value,lessons});
1404
+ };
1405
+ $("editDelete").onclick=()=>{
1406
+ if(confirm(`למחוק את הכיתה ${editing.name}? התלמידים שלה לא יוכלו להיכנס עוד. ההקלטות וההתקדמות שלהם נשמרות.`)) sendClass("DELETE");
1407
+ };
1408
 
1409
+ function useLessons(ids){ // the path shows these lessons only (null: every one)
1410
+ LESSONS=ALL_LESSONS.filter(l=>!ids||ids.includes(l.id)); if(!LESSONS.length) LESSONS=ALL_LESSONS;
1411
+ CATS=ALL_CATS.filter(c=>LESSONS.some(l=>l.id===c.lesson)); // none when every lesson of the student's is still to come
1412
+ if(CATS.length&&!CATS.some(c=>c.id===cat)) cat=CATS[0].id;
1413
+ }
1414
  const ready=fetch("/api/phrases").then(r=>r.json()).then(d=>{
1415
+ ALL_LEVELS=d.levels||[]; ALL_LESSONS=d.lessons; ALL_CATS=d.categories; PHRASES=d.phrases;
1416
+ for(const c of ALL_CATS) c.name=c.name.replace(/ ← /g,"\u00a0←\u00a0"); // the arrow never ends a line
1417
  for(const v of d.voices||[]) $("voiceId").add(new Option(v.name,v.id));
1418
  const saved=store.get("ihki.voice",""); if((d.voices||[]).some(v=>v.id===saved)) $("voiceId").value=saved;
1419
+ useLessons(MY_LESSONS);
1420
  startCat(); opened=true;
1421
  }).catch(()=>{ $("prompt").textContent="השרת לא זמין."; });
1422
  </script>
 
1425
  function enter(){
1426
  const splash=$("logo-splash");
1427
  $("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),400);
1428
+ if(MY_CLASSES.length>1&&!MY_CLASS) askClass(); // in several classes: which one now?
1429
  }
1430
  $("startBtn").onclick=$("practiceBtn").onclick=enter;
1431
  $("manageBtn").onclick=()=>{ enter(); showManage(); };
 
1453
  const res=await api("/api/me").catch(()=>null);
1454
  const ok=!res||res.status!==403; // a server that cannot be reached is not a refusal
1455
  const me=res&&res.ok?await res.json().catch(()=>({})):{}, teacher=!!me.teacher;
1456
+ const mine=me.classes||[]; // a teacher practises every lesson and is not asked for a class
1457
+ MY_CLASSES=teacher?[]:mine; MY_CLASS=null;
1458
+ $("myClass").textContent=mine.length>1?`כיתות: ${mine.map(c=>c.name).join(" · ")}`:mine.length?`כיתת ${mine[0].name}`:"";
1459
+ MY_LESSONS=me.lessons||null; if(ALL_LESSONS.length) useLessons(MY_LESSONS);
1460
+ if(MY_CLASSES.length===1) MY_CLASS=mine[0];
1461
  start.hidden=!ok||teacher; roles.hidden=$("toManage").hidden=!teacher; denied.hidden=ok;
1462
  if(ok){ (teacher?$("practiceBtn"):start).focus({preventScroll:true}); loadProgress().then(()=>{ if(PHRASES.length&&$("wrap").inert) startCat(); }); }
1463
  },0);
tests/test_scoring.py CHANGED
@@ -131,7 +131,8 @@ def test_api_end_to_end_with_stub_model():
131
  ph = c.get("/api/phrases").json()
132
  shown = {c["id"] for c in ph["categories"]} # the phrases of no lesson are in the bank, not on the page
133
  assert len(ph["phrases"]) == sum(p["cat"] in shown for p in PHRASES) and "ipa" not in ph["phrases"][0]
134
- assert TOILET["cat"] not in shown and [l["id"] for l in ph["lessons"]] == ["1", "2"]
 
135
  pid = PHRASES.index(TOILET)
136
  r = c.post("/api/score", data={"phrase_id": pid}, files={"audio": ("a.wav", buf.getvalue(), "audio/wav")})
137
  assert r.status_code == 200, r.text
@@ -354,7 +355,7 @@ def test_references_use_one_generated_voice_and_any_cached_one():
354
  def test_workbook_lesson_1():
355
  from app.phrases import CATEGORIES, LESSONS, public_phrases
356
 
357
- assert [l["id"] for l in LESSONS] == ["1", "2"]
358
  assert [c["id"] for c in CATEGORIES if c["lesson"] == "1"] == ["l1-pron", "l1-verbs", "l1-past", "l1-say", "l1-read"]
359
  pub = public_phrases()
360
  count = lambda cat: sum(p["cat"] == cat for p in pub)
@@ -377,6 +378,19 @@ def test_workbook_lesson_1():
377
  assert any(i.startswith("i l m ʕ a l l i m | q a ʕ a d") for _, i in p["variants"]) # rural ق accepted
378
 
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  def test_workbook_lesson_2():
381
  from app.phrases import CATEGORIES, public_phrases
382
 
@@ -424,11 +438,12 @@ def test_sign_in_guards_the_practice_endpoints():
424
  def classes(self, fresh=False):
425
  return self.cls
426
 
427
- def save_class(self, id, name, emails, nicknames):
 
428
  if id is None:
429
- self.cls.insert(0, {"id": len(self.cls) + 1, "name": name, "emails": emails, "nicknames": nicknames})
430
  elif c := next((c for c in self.cls if c["id"] == id), None):
431
- c.update(name=name, emails=emails, nicknames=nicknames)
432
  else:
433
  raise KeyError(id)
434
 
@@ -461,45 +476,63 @@ def test_sign_in_guards_the_practice_endpoints():
461
  assert c.get("/api/tts/0", headers={"Authorization": "Bearer bad"}).status_code == 401
462
  assert c.get("/api/tts/0", headers={"Authorization": "Bearer good"}).status_code == 503 # in, but no TTS key
463
  good = {"Authorization": "Bearer good"}
464
- # no class yet: any account is let in
465
- assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False, "class": ""}
 
 
466
  assert c.get("/api/class").status_code == 401 and c.get("/api/class", headers=good).status_code == 403
467
- assert c.post("/api/classes", json={"name": "x", "emails": ""}, headers=good).status_code == 403 # teachers only
468
- assert c.get("/api/classmates", headers=good).json() == {"class": "", "students": [], "unnamed": 0}
469
- c.app.state.auth.cls = [{"id": 1, "name": "September 2026", "emails": ["other@b.c"], "nicknames": {}}] # a class without this account
 
470
  assert c.get("/api/me", headers=good).status_code == 403 and c.get("/api/tts/0", headers=good).status_code == 403
471
- c.app.state.auth.cls[0]["emails"].append("a@b.c") # case does not matter; the student sees their class
472
- assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False, "class": "September 2026"}
473
- # a teacher is let in without a class, and opens classes in the panel
 
 
 
 
 
 
 
474
  c.app.state.auth.cls, c.app.state.auth.teachers = [], frozenset({"a@b.c"})
475
- assert c.post("/api/classes", json={"name": "September 2026", "emails": "x@b.c"}, headers=good).status_code == 200
476
- assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": True, "class": ""}
477
- new_class = {"name": " October 2026 ", "emails": "S@b.c, new@b.c\nnew@b.c דנה \n"} # an optional nickname after the address
 
 
478
  assert c.post("/api/classes", json=new_class, headers=good).status_code == 200
479
- assert c.app.state.auth.cls[0] == {"id": 2, "name": "October 2026", "emails": ["new@b.c", "s@b.c"],
480
- "nicknames": {"new@b.c": "דנה"}}
481
- for body, code in (({"name": "", "emails": ""}, 400), ({"name": "y", "emails": "not-an-email"}, 400),
482
- ({"name": "y", "emails": "z@b.c w@b.c Dana"}, 400), # whose nickname?
483
- ({"name": "October 2026", "emails": ""}, 409), # the name is taken
484
- ({"name": "y", "emails": "x@b.c"}, 409)): # x is in September's class
 
 
485
  r = c.post("/api/classes", json=body, headers=good)
486
  assert r.status_code == code, (body, r.text)
487
- assert "September 2026" in r.json()["detail"]
488
- assert c.put("/api/classes/9", json={"name": "z", "emails": ""}, headers=good).status_code == 404
489
- assert c.put("/api/classes/1", json={"name": "September 2026", "emails": "x@b.c y@b.c"}, headers=good).status_code == 200
490
- assert c.delete("/api/classes/1", headers=good).status_code == 200 and len(c.app.state.auth.cls) == 1
491
- # the panel lists the classes' accounts, with their class; the teacher, in no class, is not a student
 
 
492
  d = c.get("/api/class", headers=good).json()
493
- assert [k["name"] for k in d["classes"]] == ["October 2026"]
494
- student, new = d["students"]
495
  assert student["best"] == {str(PHRASES.index(VERB)): 88} and student["tries"] == {str(PHRASES.index(VERB)): 2}
496
  assert (student["recordings"], student["days"], student["last"]) == (2, 1, "2026-10-09T10:00:00+00:00")
497
  assert (new["email"], new["joined"], new["recordings"]) == ("new@b.c", False, 0) and student["joined"]
498
- assert student["class"] == new["class"] == "October 2026" and (student["nick"], new["nick"]) == ("", "דנה")
499
- # a student sees their class: by nickname, else the Google name; one with neither is only counted
 
 
500
  c.app.state.auth.cls[0]["emails"].append("a@b.c")
501
- assert c.get("/api/classmates", headers=good).json() == {"class": "October 2026", "unnamed": 1, "students": [
502
- {"name": "Alef", "me": True}, {"name": "דנה", "me": False}]}
503
  c.app.state.auth.cls[0]["emails"].remove("a@b.c")
504
  c.app.state.auth.cls = [] # no class: every account that signed in
505
  assert [s["email"] for s in c.get("/api/class", headers=good).json()["students"]] == ["A@b.c", "s@b.c", "out@b.c"]
 
131
  ph = c.get("/api/phrases").json()
132
  shown = {c["id"] for c in ph["categories"]} # the phrases of no lesson are in the bank, not on the page
133
  assert len(ph["phrases"]) == sum(p["cat"] in shown for p in PHRASES) and "ipa" not in ph["phrases"][0]
134
+ assert TOILET["cat"] not in shown and [l["id"] for l in ph["lessons"] if not l.get("soon")] == ["1", "2"]
135
+ assert len(ph["lessons"]) == 20 and [v["name"] for v in ph["levels"]] == ["רמה 1", "רמה 2"]
136
  pid = PHRASES.index(TOILET)
137
  r = c.post("/api/score", data={"phrase_id": pid}, files={"audio": ("a.wav", buf.getvalue(), "audio/wav")})
138
  assert r.status_code == 200, r.text
 
355
  def test_workbook_lesson_1():
356
  from app.phrases import CATEGORIES, LESSONS, public_phrases
357
 
358
+ assert [l["id"] for l in LESSONS if not l.get("soon")] == ["1", "2"]
359
  assert [c["id"] for c in CATEGORIES if c["lesson"] == "1"] == ["l1-pron", "l1-verbs", "l1-past", "l1-say", "l1-read"]
360
  pub = public_phrases()
361
  count = lambda cat: sum(p["cat"] == cat for p in pub)
 
378
  assert any(i.startswith("i l m ʕ a l l i m | q a ʕ a d") for _, i in p["variants"]) # rural ق accepted
379
 
380
 
381
+ def test_levels_plan_every_lesson():
382
+ from app.phrases import CATEGORIES, LESSONS, LEVELS
383
+ from app.workbook import LESSONS as WRITTEN, LEVELS as PLAN, lesson_id
384
+
385
+ # every lesson names its level, and each level lists all of its planned lessons, the unwritten ones as soon
386
+ assert [v["id"] for v in LEVELS] == ["1", "2"] and len(LESSONS) == 20
387
+ assert all(isinstance(key, tuple) and key[0] in PLAN for key in WRITTEN)
388
+ assert [(l["level"], l["n"]) for l in LESSONS] == [(str(v), n) for v in (1, 2) for n in range(1, 11)]
389
+ assert [l["id"] for l in LESSONS if l["level"] == "2"][:2] == ["2.1", "2.2"] # level 1 keeps "1", "2": attempts name them
390
+ assert {l["id"] for l in LESSONS if not l.get("soon")} == {lesson_id(*k) for k in WRITTEN} == {c["lesson"] for c in CATEGORIES}
391
+ assert lesson_id(2, 1) == "2.1" and lesson_id(1, 3) == "3"
392
+
393
+
394
  def test_workbook_lesson_2():
395
  from app.phrases import CATEGORIES, public_phrases
396
 
 
438
  def classes(self, fresh=False):
439
  return self.cls
440
 
441
+ def save_class(self, id, name, emails, nicknames, lessons):
442
+ row = {"name": name, "emails": emails, "nicknames": nicknames, "lessons": lessons}
443
  if id is None:
444
+ self.cls.insert(0, {"id": len(self.cls) + 1, **row})
445
  elif c := next((c for c in self.cls if c["id"] == id), None):
446
+ c.update(row)
447
  else:
448
  raise KeyError(id)
449
 
 
476
  assert c.get("/api/tts/0", headers={"Authorization": "Bearer bad"}).status_code == 401
477
  assert c.get("/api/tts/0", headers={"Authorization": "Bearer good"}).status_code == 503 # in, but no TTS key
478
  good = {"Authorization": "Bearer good"}
479
+ from app.phrases import LESSONS
480
+ ALL = [l["id"] for l in LESSONS]
481
+ # no class yet: any account is let in, to every lesson
482
+ assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False, "classes": [], "lessons": ALL}
483
  assert c.get("/api/class").status_code == 401 and c.get("/api/class", headers=good).status_code == 403
484
+ assert c.post("/api/classes", json={"name": "x", "emails": "", "lessons": ALL}, headers=good).status_code == 403 # teachers only
485
+ assert c.get("/api/classmates", headers=good).json() == {"classes": []}
486
+ beginners = {"id": 1, "name": "Beginners", "emails": ["other@b.c"], "nicknames": {}, "lessons": ALL[:1]}
487
+ c.app.state.auth.cls = [beginners] # a class without this account
488
  assert c.get("/api/me", headers=good).status_code == 403 and c.get("/api/tts/0", headers=good).status_code == 403
489
+ beginners["emails"].append("a@b.c") # case does not matter; the student studies their class's lessons
490
+ assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False, "lessons": ALL[:1],
491
+ "classes": [{"name": "Beginners", "lessons": ALL[:1]}]}
492
+ # in a second class too: the lessons of both, in the course's order
493
+ c.app.state.auth.cls = [{"id": 2, "name": "Advanced", "emails": ["a@b.c"], "nicknames": {}, "lessons": ALL[1:]}, beginners]
494
+ me = c.get("/api/me", headers=good).json() # each class with its own lessons: the student chooses one to practise in
495
+ assert me["lessons"] == ALL and me["classes"] == [{"name": "Advanced", "lessons": ALL[1:]}, {"name": "Beginners", "lessons": ALL[:1]}]
496
+ c.app.state.auth.cls = [{**beginners, "lessons": []}] # a class saved before classes had lessons has every one
497
+ assert c.get("/api/me", headers=good).json()["lessons"] == ALL
498
+ # a teacher is let in without a class, studies every lesson, and opens classes in the panel
499
  c.app.state.auth.cls, c.app.state.auth.teachers = [], frozenset({"a@b.c"})
500
+ assert c.post("/api/classes", json={"name": "September 2026", "emails": "x@b.c", "lessons": ALL[:1]},
501
+ headers=good).status_code == 200
502
+ assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": True, "classes": [], "lessons": ALL}
503
+ new_class = {"name": " October 2026 ", "emails": "S@b.c, new@b.c\nnew@b.c דנה \nx@b.c\n", # an optional nickname
504
+ "lessons": ALL[::-1]} # x is in September's class too: a student may be in several
505
  assert c.post("/api/classes", json=new_class, headers=good).status_code == 200
506
+ assert c.app.state.auth.cls[0] == {"id": 2, "name": "October 2026", "emails": ["new@b.c", "s@b.c", "x@b.c"],
507
+ "nicknames": {"new@b.c": "דנה"}, "lessons": ALL}
508
+ for body, code in (({"name": "", "emails": "", "lessons": ALL}, 400), ({"name": "y", "emails": "not-an-email", "lessons": ALL}, 400),
509
+ ({"name": "y", "emails": "z@b.c w@b.c Dana", "lessons": ALL}, 400), # whose nickname?
510
+ ({"name": "y", "emails": "", "lessons": []}, 400), ({"name": "y", "emails": ""}, 400), # no lesson
511
+ ({"name": "y", "emails": "", "lessons": ["no-such-lesson"]}, 400),
512
+ ({"name": "October 2026", "emails": "", "lessons": ALL}, 409), # the name is taken,
513
+ ({"name": " october 2026", "emails": "", "lessons": ALL}, 409)): # in any letter case and spacing
514
  r = c.post("/api/classes", json=body, headers=good)
515
  assert r.status_code == code, (body, r.text)
516
+ assert "October 2026" in r.json()["detail"]
517
+ # renaming a class to another's name is refused too; keeping its own name is not
518
+ assert c.put("/api/classes/1", json={"name": "OCTOBER 2026", "emails": "", "lessons": ALL}, headers=good).status_code == 409
519
+ assert c.put("/api/classes/9", json={"name": "z", "emails": "", "lessons": ALL}, headers=good).status_code == 404
520
+ assert c.put("/api/classes/1", json={"name": "September 2026", "emails": "x@b.c y@b.c", "lessons": ALL[:1]},
521
+ headers=good).status_code == 200
522
+ # the panel: the classes with their lessons; the classes' accounts, each with {class: nickname}
523
  d = c.get("/api/class", headers=good).json()
524
+ assert [(k["name"], k["lessons"]) for k in d["classes"]] == [("October 2026", ALL), ("September 2026", ALL[:1])]
525
+ student, new, x, y = d["students"] # the teacher, in no class, is not a student
526
  assert student["best"] == {str(PHRASES.index(VERB)): 88} and student["tries"] == {str(PHRASES.index(VERB)): 2}
527
  assert (student["recordings"], student["days"], student["last"]) == (2, 1, "2026-10-09T10:00:00+00:00")
528
  assert (new["email"], new["joined"], new["recordings"]) == ("new@b.c", False, 0) and student["joined"]
529
+ assert (student["classes"], new["classes"]) == ({"October 2026": ""}, {"October 2026": "דנה"})
530
+ assert x["classes"] == {"October 2026": "", "September 2026": ""} and y["classes"] == {"September 2026": ""}
531
+ assert c.delete("/api/classes/1", headers=good).status_code == 200 and len(c.app.state.auth.cls) == 1
532
+ # a student sees each of their classes: by nickname, else the Google name; one with neither is only counted
533
  c.app.state.auth.cls[0]["emails"].append("a@b.c")
534
+ assert c.get("/api/classmates", headers=good).json() == {"classes": [{"name": "October 2026", "unnamed": 2, "students": [
535
+ {"name": "Alef", "me": True}, {"name": "דנה", "me": False}]}]}
536
  c.app.state.auth.cls[0]["emails"].remove("a@b.c")
537
  c.app.state.auth.cls = [] # no class: every account that signed in
538
  assert [s["email"] for s in c.get("/api/class", headers=good).json()["students"]] == ["A@b.c", "s@b.c", "out@b.c"]