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"]