3v324v23 commited on
Commit
f90708b
·
1 Parent(s): 1c2ce5a

Refactor logo animation and update service worker cache

Browse files

- Updated the logo animation in `logo-intro.svg` to enhance visual effects and timing.
- Changed the service worker cache version from v7 to v17 and removed an unused logo file.
- Adjusted test cases to reflect the updated logo path.
- Introduced a new `live.py` module to manage live class sessions, including session creation, participant management, and question handling.
- Added tests for the new live session functionality to ensure correct behavior for student participation and session lifecycle.

README.md CHANGED
@@ -576,6 +576,7 @@ create table public.classes (
576
  nicknames jsonb not null default '{}', -- {email: nickname}, for the students who have one
577
  lessons text[] not null default '{}', -- the lesson ids the class studies; empty = every lesson
578
  subject text not null default 'ערבית', -- existing classes; new ones copy the creating admin
 
579
  homework text not null default '', -- the topic id the class practises now, e.g. l2-talk; '' = none
580
  due date, -- the day the homework is due, if it has one
581
  homeworks jsonb not null default '[]', -- every homework of the class: [{"topic": ..., "due": ...}]
@@ -593,6 +594,7 @@ alter table public.classes add column if not exists nicknames jsonb not null def
593
  add column if not exists homework text not null default '',
594
  add column if not exists due date,
595
  add column if not exists homeworks jsonb not null default '[]',
 
596
  add column if not exists dailies jsonb not null default '[]',
597
  add column if not exists subject text not null default 'ערבית';
598
  update public.classes set subject = 'ערבית' where subject = '';
@@ -600,6 +602,23 @@ update public.classes set subject = 'ערבית' where subject = '';
600
 
601
  The server keeps the list for a minute, so a change in the SQL editor (rather than the panel) applies within that time.
602
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  The **ניטור** activity dashboard can switch between the last 5 minutes, 24 hours and 7 days. It also reports Sawtak cache usage: cached WAV count, disk size, cache hits, generated misses, and the hit rate since the current server process started. `GET /api/tts-cache` returns only these aggregate figures; clearing the cache resets the process counters.
604
 
605
  The panel reads through `GET /api/class`, which answers teachers only. Row-level security hides other students' rows from the teacher's own token, so the server reads them with `SUPABASE_SECRET_KEY` (Supabase → Project Settings → API Keys → secret key). Unlike the publishable key it must stay private: set it as a Space **secret**. Without it the panel opens and says the data is not available.
 
576
  nicknames jsonb not null default '{}', -- {email: nickname}, for the students who have one
577
  lessons text[] not null default '{}', -- the lesson ids the class studies; empty = every lesson
578
  subject text not null default 'ערבית', -- existing classes; new ones copy the creating admin
579
+ teachers text[] not null default '{}', -- class-scoped teachers; each address is also in emails
580
  homework text not null default '', -- the topic id the class practises now, e.g. l2-talk; '' = none
581
  due date, -- the day the homework is due, if it has one
582
  homeworks jsonb not null default '[]', -- every homework of the class: [{"topic": ..., "due": ...}]
 
594
  add column if not exists homework text not null default '',
595
  add column if not exists due date,
596
  add column if not exists homeworks jsonb not null default '[]',
597
+ add column if not exists teachers text[] not null default '{}',
598
  add column if not exists dailies jsonb not null default '[]',
599
  add column if not exists subject text not null default 'ערבית';
600
  update public.classes set subject = 'ערבית' where subject = '';
 
602
 
603
  The server keeps the list for a minute, so a change in the SQL editor (rather than the panel) applies within that time.
604
 
605
+ ### Live lessons
606
+
607
+ An admin can choose several class teachers from the class's own student list. The assignment is scoped to that class:
608
+ a person may teach one class and remain a student in another. Developers can edit every class; subject admins can edit
609
+ only classes in their assigned subject.
610
+
611
+ A class teacher opens **שיעור חי**, chooses their class and starts its single active session. Students see active sessions
612
+ from their registered classes in the same tab. A six-digit code is also shown as a fallback; any signed-in account may
613
+ join with it for that session only, without becoming a member of the class. The teacher chooses an existing sentence or
614
+ searches the class's lesson bank by Hebrew text. No translation or LLM is used.
615
+
616
+ The page polls every three seconds. The teacher sees the whole roster, who has joined, who is online, and each submitted
617
+ score; a student sees only their own result. Every student gets one scored attempt per question. Empty or undecodable
618
+ recordings may be retried because they never became a scored attempt. Session state, attendance and answers live only in
619
+ the application process: ending a session, restarting the server, or two hours without a teacher heartbeat erases them.
620
+ No live-session audio or result history is stored.
621
+
622
  The **ניטור** activity dashboard can switch between the last 5 minutes, 24 hours and 7 days. It also reports Sawtak cache usage: cached WAV count, disk size, cache hits, generated misses, and the hit rate since the current server process started. `GET /api/tts-cache` returns only these aggregate figures; clearing the cache resets the process counters.
623
 
624
  The panel reads through `GET /api/class`, which answers teachers only. Row-level security hides other students' rows from the teacher's own token, so the server reads them with `SUPABASE_SECRET_KEY` (Supabase → Project Settings → API Keys → secret key). Unlike the publishable key it must stay private: set it as a Space **secret**. Without it the panel opens and says the data is not available.
app/auth.py CHANGED
@@ -259,6 +259,7 @@ class SupabaseAuth:
259
  r["nicknames"], r["lessons"] = r.get("nicknames") or {}, r.get("lessons") or []
260
  r["homework"], r["due"] = r.get("homework") or "", r.get("due") or None
261
  r["homeworks"], r["dailies"] = r.get("homeworks") or [], r.get("dailies") or []
 
262
  except AuthUnavailable as e:
263
  if "answered 404" not in str(e):
264
  raise
@@ -269,7 +270,7 @@ class SupabaseAuth:
269
 
270
  def save_class(self, id: int | None, name: str, emails: list[str], nicknames: dict[str, str],
271
  lessons: list[str], homeworks: list[dict] = (), dailies: list[dict] | None = None,
272
- subject: str = "") -> None:
273
  """Adds a class (id None) or replaces one's name, emails, nicknames, lessons and homeworks ([{"topic", "due"}]),
274
  and its daily videos' days ([{"video", "date"}]; None leaves them as they are, so a table made before them
275
  still takes a class that has none). A name already taken raises ClassConflict."""
@@ -278,6 +279,8 @@ class SupabaseAuth:
278
  "homework": first["topic"], "due": first["due"], "homeworks": list(homeworks)}
279
  if dailies is not None:
280
  body["dailies"] = list(dailies)
 
 
281
  if id is None:
282
  body["subject"] = subject
283
  self._admin("/rest/v1/classes", "POST", body)
 
259
  r["nicknames"], r["lessons"] = r.get("nicknames") or {}, r.get("lessons") or []
260
  r["homework"], r["due"] = r.get("homework") or "", r.get("due") or None
261
  r["homeworks"], r["dailies"] = r.get("homeworks") or [], r.get("dailies") or []
262
+ r["teachers"] = [str(e).lower() for e in (r.get("teachers") or [])]
263
  except AuthUnavailable as e:
264
  if "answered 404" not in str(e):
265
  raise
 
270
 
271
  def save_class(self, id: int | None, name: str, emails: list[str], nicknames: dict[str, str],
272
  lessons: list[str], homeworks: list[dict] = (), dailies: list[dict] | None = None,
273
+ subject: str = "", teachers: list[str] | None = None) -> None:
274
  """Adds a class (id None) or replaces one's name, emails, nicknames, lessons and homeworks ([{"topic", "due"}]),
275
  and its daily videos' days ([{"video", "date"}]; None leaves them as they are, so a table made before them
276
  still takes a class that has none). A name already taken raises ClassConflict."""
 
279
  "homework": first["topic"], "due": first["due"], "homeworks": list(homeworks)}
280
  if dailies is not None:
281
  body["dailies"] = list(dailies)
282
+ if teachers is not None:
283
+ body["teachers"] = list(teachers)
284
  if id is None:
285
  body["subject"] = subject
286
  self._admin("/rest/v1/classes", "POST", body)
app/live.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Temporary live-class state.
2
+
3
+ Live sessions deliberately live in process memory: class/teacher assignments are durable in
4
+ Supabase, while attendance, questions and answers disappear when a session ends or the service
5
+ restarts. The browser polls this store; no recording bytes are retained here.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import secrets
10
+ import threading
11
+ import time
12
+ from datetime import datetime, timezone
13
+
14
+
15
+ SESSION_TTL = 2 * 60 * 60
16
+ PRESENCE_TTL = 20
17
+
18
+
19
+ def _now_iso() -> str:
20
+ return datetime.now(timezone.utc).isoformat()
21
+
22
+
23
+ class LiveConflict(ValueError):
24
+ pass
25
+
26
+
27
+ class LiveStore:
28
+ def __init__(self):
29
+ self._lock = threading.Lock()
30
+ self._sessions: dict[str, dict] = {}
31
+
32
+ def _prune(self, now: float) -> None:
33
+ for code, session in list(self._sessions.items()):
34
+ if session["ended"] or now - session["teacher_seen"] > SESSION_TTL:
35
+ self._sessions.pop(code, None)
36
+
37
+ def _new_code(self) -> str:
38
+ for _ in range(100):
39
+ code = f"{secrets.randbelow(1_000_000):06d}"
40
+ if code not in self._sessions:
41
+ return code
42
+ raise RuntimeError("could not allocate a live-session code")
43
+
44
+ def start(self, course: dict, teacher: dict) -> dict:
45
+ now = time.monotonic()
46
+ with self._lock:
47
+ self._prune(now)
48
+ existing = next((s for s in self._sessions.values() if s["class_id"] == course["id"]), None)
49
+ if existing:
50
+ existing["teacher_seen"] = now
51
+ return self._public(existing)
52
+ code = self._new_code()
53
+ roster = {email: (course.get("nicknames") or {}).get(email) or email.split("@", 1)[0]
54
+ for email in course.get("emails", [])}
55
+ session = {"code": code, "class_id": course["id"], "class_name": course["name"],
56
+ "teacher_id": teacher["id"], "teacher_email": teacher["email"].lower(),
57
+ "started_at": _now_iso(), "teacher_seen": now, "ended": False,
58
+ "question": None, "participants": {}, "roster": roster, "answers": {}, "question_no": 0}
59
+ self._sessions[code] = session
60
+ return self._public(session)
61
+
62
+ def list_for(self, class_ids: set[int], user_id: str) -> list[dict]:
63
+ now = time.monotonic()
64
+ with self._lock:
65
+ self._prune(now)
66
+ return [self._public(s) for s in self._sessions.values()
67
+ if s["class_id"] in class_ids or user_id in s["participants"]]
68
+
69
+ def get(self, code: str) -> dict | None:
70
+ now = time.monotonic()
71
+ with self._lock:
72
+ self._prune(now)
73
+ return self._sessions.get(code)
74
+
75
+ def join(self, code: str, user: dict, name: str = "") -> dict:
76
+ now = time.monotonic()
77
+ with self._lock:
78
+ self._prune(now)
79
+ session = self._sessions.get(code)
80
+ if not session:
81
+ raise KeyError(code)
82
+ session["participants"][user["id"]] = {"email": user["email"].lower(),
83
+ "name": name or user["email"].split("@", 1)[0],
84
+ "last_seen": now}
85
+ return self._public(session)
86
+
87
+ def heartbeat(self, code: str, user: dict, teacher: bool = False) -> dict:
88
+ now = time.monotonic()
89
+ with self._lock:
90
+ self._prune(now)
91
+ session = self._sessions.get(code)
92
+ if not session:
93
+ raise KeyError(code)
94
+ if teacher:
95
+ session["teacher_seen"] = now
96
+ elif user["id"] in session["participants"]:
97
+ session["participants"][user["id"]]["last_seen"] = now
98
+ else:
99
+ raise PermissionError(code)
100
+ return self._detail(session, user["id"], teacher, now)
101
+
102
+ def ask(self, code: str, phrase: dict) -> dict:
103
+ now = time.monotonic()
104
+ with self._lock:
105
+ session = self._sessions.get(code)
106
+ if not session:
107
+ raise KeyError(code)
108
+ session["question_no"] += 1
109
+ session["question"] = {"no": session["question_no"], "phrase_id": phrase["id"],
110
+ "he": phrase["he"], "ar": phrase["ar"], "tr": phrase["tr"],
111
+ "started_at": _now_iso()}
112
+ session["answers"] = {}
113
+ session["teacher_seen"] = now
114
+ return self._public(session)
115
+
116
+ def begin_answer(self, code: str, user_id: str) -> dict:
117
+ with self._lock:
118
+ session = self._sessions.get(code)
119
+ if not session or not session["question"]:
120
+ raise KeyError(code)
121
+ if user_id not in session["participants"]:
122
+ raise PermissionError(code)
123
+ if user_id in session["answers"]:
124
+ raise LiveConflict("one attempt only")
125
+ session["answers"][user_id] = {"pending": True}
126
+ return dict(session["question"])
127
+
128
+ def cancel_answer(self, code: str, user_id: str) -> None:
129
+ with self._lock:
130
+ session = self._sessions.get(code)
131
+ if session and session["answers"].get(user_id, {}).get("pending"):
132
+ session["answers"].pop(user_id, None)
133
+
134
+ def finish_answer(self, code: str, user_id: str, answer: dict) -> None:
135
+ with self._lock:
136
+ session = self._sessions.get(code)
137
+ if not session or user_id not in session["answers"]:
138
+ raise KeyError(code)
139
+ session["answers"][user_id] = answer
140
+
141
+ def end(self, code: str) -> None:
142
+ with self._lock:
143
+ if code not in self._sessions:
144
+ raise KeyError(code)
145
+ self._sessions.pop(code, None)
146
+
147
+ @staticmethod
148
+ def _public(session: dict) -> dict:
149
+ return {k: session[k] for k in ("code", "class_id", "class_name", "started_at", "question")}
150
+
151
+ def _detail(self, session: dict, user_id: str, teacher: bool, now: float) -> dict:
152
+ base = self._public(session)
153
+ if teacher:
154
+ participants = []
155
+ joined_emails = set()
156
+ for uid, person in session["participants"].items():
157
+ joined_emails.add(person["email"])
158
+ answer = session["answers"].get(uid)
159
+ participants.append({"id": uid, "name": person["name"], "email": person["email"],
160
+ "online": now - person["last_seen"] <= PRESENCE_TTL,
161
+ "answer": None if not answer or answer.get("pending") else answer})
162
+ participants += [{"id": "", "name": name, "email": email, "online": False, "answer": None}
163
+ for email, name in session["roster"].items() if email not in joined_emails]
164
+ return {**base, "participants": participants}
165
+ answer = session["answers"].get(user_id)
166
+ return {**base, "answered": user_id in session["answers"],
167
+ "answer": None if not answer or answer.get("pending") else answer}
app/main.py CHANGED
@@ -35,6 +35,7 @@ from . import lab
35
  from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
36
  from .keystore import (CREATE_APP_SECRETS, MAX_LEN as MAX_KEY_LEN, MIGRATE_APP_SECRETS, NAMES as KEY_NAMES, KeyRefused,
37
  check as check_key, load_keys)
 
38
  from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
39
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
40
  from .reference import References
@@ -53,7 +54,7 @@ STATIC = Path(__file__).resolve().parent.parent / "static"
53
  load_dotenv(STATIC.parent / ".env")
54
  # A class topic's opening screen, by the path it is served at; "/" is Arabic.
55
  SUBJECTS = {
56
- "arabic": {"__TITLE__": "אהלא וסהלא", "__SPLASH_LOGO__": "/static/logo-intro-alt.svg",
57
  "__SPLASH_ALT__": "אהלא וסהלא, בית הספר ללימוד השפה הערבית", "__START_LABEL__": "יאללה נבלש"},
58
  "math": {"__TITLE__": "מתמטיקה בכיף", "__SPLASH_LOGO__": "/static/logo-math.svg",
59
  "__SPLASH_ALT__": "מתמטיקה בכיף, המורה הפרטי למתמטיקה", "__START_LABEL__": "יאללה נתחיל"},
@@ -230,7 +231,8 @@ MIGRATE_CLASSES = ("alter table public.classes add column if not exists nickname
230
  "add column if not exists homework text not null default '', add column if not exists due date, "
231
  "add column if not exists homeworks jsonb not null default '[]', "
232
  "add column if not exists dailies jsonb not null default '[]', "
233
- "add column if not exists subject text not null default 'ערבית'; "
 
234
  "update public.classes set subject = 'ערבית' where subject = '';")
235
  MAX_HOMEWORKS = 30
236
 
@@ -451,6 +453,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
451
 
452
  app = FastAPI(title="ihki", lifespan=lifespan)
453
  app.state.verb_game = VerbExerciseGenerator()
 
454
  app.mount("/static", StaticFiles(directory=STATIC), name="static")
455
 
456
  def signed_in(request: Request) -> dict | None:
@@ -471,6 +474,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
471
  except AuthUnavailable as e:
472
  raise HTTPException(503, f"class list not available: {e}")
473
  mine = [c for c in classes if email in c["emails"]] # a student may be in several classes
 
474
  roles, entitlements, assigned = {"student"}, set(DEFAULT_ENTITLEMENTS), None
475
  if hasattr(a, "access_for"):
476
  try:
@@ -501,7 +505,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
501
  return {**user, "token": token.strip(), "roles": sorted(roles), "entitlements": sorted(entitlements),
502
  "subject": str((assigned or {}).get("subject") or ""),
503
  "permissions": sorted(permissions), "teacher": bool(roles & {"teacher", "admin", "developer"}),
504
- "tutor": "flags.create" in permissions or "*" in permissions, "classes": mine}
505
 
506
  def require(permission: str):
507
  def allowed(user: dict | None = Depends(signed_in)) -> SupabaseAuth:
@@ -609,8 +613,135 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
609
  **({"dailies": sorted(every.values(), key=lambda d: (d["date"], d["id"]))} if can_all else {}),
610
  "classes": [{"name": c["name"], "lessons": lessons_of(c), **homework_of(c, topics()),
611
  "dailies": dailies_of(c, daily_rows())} for c in mine],
 
 
612
  "lessons": lessons}
613
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614
  @app.get("/api/progress")
615
  def progress(user: dict | None = Depends(signed_in)):
616
  """The student's best scores and compact practice totals, including this week's active days."""
@@ -1551,7 +1682,7 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1551
  if "answered 404" in str(e):
1552
  return ("הטבלה classes לא קיימת ב-Supabase. צרו אותה ב-SQL Editor (הפקודה ב-README); "
1553
  "אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
1554
- if any(col in str(e) for col in ("nicknames", "lessons", "homework", "'due'", "dailies", "subject")): # a table made before them
1555
  return f"לטבלה classes חסרות עמודות. הריצו ב-SQL Editor של Supabase: {MIGRATE_CLASSES}"
1556
  return f"Supabase לא זמין: {e}"
1557
 
@@ -1572,6 +1703,13 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1572
  if not isinstance(lessons, list) or not lessons or not set(map(str, lessons)) <= set(ALL_LESSONS):
1573
  raise HTTPException(400, "בחרו לפחות שיעור אחד לכיתה")
1574
  lessons = [l for l in ALL_LESSONS if l in map(str, lessons)]
 
 
 
 
 
 
 
1575
  listed = body.get("homeworks")
1576
  if listed is None:
1577
  listed = [{"topic": body.get("homework"), "due": body.get("due")}]
@@ -1620,7 +1758,11 @@ def create_app(scorer: Scorer | None = None, tts: SawtakTTS | None = None,
1620
  if (id is None and hasattr(a, "access_for") and actor and "admin" in actor.get("roles", ())
1621
  and "developer" not in actor.get("roles", ()) and not subject):
1622
  raise HTTPException(409, "לא הוגדר מקצוע למנהל/ת. מפתח/ת צריך להגדיר אותו בניטור.")
1623
- a.save_class(id, name, emails, nicks, lessons, homeworks, dailies if dailies or had else None, subject)
 
 
 
 
1624
  except ClassConflict:
1625
  raise HTTPException(409, f"כבר יש כיתה בשם {name}")
1626
  except KeyError:
 
35
  from .auth import AuthUnavailable, ClassConflict, SupabaseAuth, load_auth
36
  from .keystore import (CREATE_APP_SECRETS, MAX_LEN as MAX_KEY_LEN, MIGRATE_APP_SECRETS, NAMES as KEY_NAMES, KeyRefused,
37
  check as check_key, load_keys)
38
+ from .live import LiveConflict, LiveStore
39
  from .phrases import CATEGORIES, GAMES, LESSONS, LEVELS, PHRASES, public_phrases
40
  from .prompt_tts import ENGINES, PromptTTSUnavailable, hebrew_mp3
41
  from .reference import References
 
54
  load_dotenv(STATIC.parent / ".env")
55
  # A class topic's opening screen, by the path it is served at; "/" is Arabic.
56
  SUBJECTS = {
57
+ "arabic": {"__TITLE__": "אהלא וסהלא", "__SPLASH_LOGO__": "/static/logo-intro.svg",
58
  "__SPLASH_ALT__": "אהלא וסהלא, בית הספר ללימוד השפה הערבית", "__START_LABEL__": "יאללה נבלש"},
59
  "math": {"__TITLE__": "מתמטיקה בכיף", "__SPLASH_LOGO__": "/static/logo-math.svg",
60
  "__SPLASH_ALT__": "מתמטיקה בכיף, המורה הפרטי למתמטיקה", "__START_LABEL__": "יאללה נתחיל"},
 
231
  "add column if not exists homework text not null default '', add column if not exists due date, "
232
  "add column if not exists homeworks jsonb not null default '[]', "
233
  "add column if not exists dailies jsonb not null default '[]', "
234
+ "add column if not exists subject text not null default 'ערבית', "
235
+ "add column if not exists teachers text[] not null default '{}'; "
236
  "update public.classes set subject = 'ערבית' where subject = '';")
237
  MAX_HOMEWORKS = 30
238
 
 
453
 
454
  app = FastAPI(title="ihki", lifespan=lifespan)
455
  app.state.verb_game = VerbExerciseGenerator()
456
+ app.state.live = LiveStore()
457
  app.mount("/static", StaticFiles(directory=STATIC), name="static")
458
 
459
  def signed_in(request: Request) -> dict | None:
 
474
  except AuthUnavailable as e:
475
  raise HTTPException(503, f"class list not available: {e}")
476
  mine = [c for c in classes if email in c["emails"]] # a student may be in several classes
477
+ teaches = [c for c in classes if email in (c.get("teachers") or [])]
478
  roles, entitlements, assigned = {"student"}, set(DEFAULT_ENTITLEMENTS), None
479
  if hasattr(a, "access_for"):
480
  try:
 
505
  return {**user, "token": token.strip(), "roles": sorted(roles), "entitlements": sorted(entitlements),
506
  "subject": str((assigned or {}).get("subject") or ""),
507
  "permissions": sorted(permissions), "teacher": bool(roles & {"teacher", "admin", "developer"}),
508
+ "tutor": "flags.create" in permissions or "*" in permissions, "classes": mine, "teaches": teaches}
509
 
510
  def require(permission: str):
511
  def allowed(user: dict | None = Depends(signed_in)) -> SupabaseAuth:
 
613
  **({"dailies": sorted(every.values(), key=lambda d: (d["date"], d["id"]))} if can_all else {}),
614
  "classes": [{"name": c["name"], "lessons": lessons_of(c), **homework_of(c, topics()),
615
  "dailies": dailies_of(c, daily_rows())} for c in mine],
616
+ **({"teaches": [{"id": c["id"], "name": c["name"], "lessons": lessons_of(c)}
617
+ for c in user.get("teaches", [])]} if user and user.get("teaches") else {}),
618
  "lessons": lessons}
619
 
620
+ def live_course(class_id: int) -> dict:
621
+ course = next((c for c in app.state.auth.classes() if c["id"] == class_id), None)
622
+ if not course:
623
+ raise HTTPException(404, "הכיתה לא נמצאה")
624
+ return course
625
+
626
+ def live_teacher(course: dict, user: dict | None) -> None:
627
+ if not user or user["email"].lower() not in (course.get("teachers") or []):
628
+ raise HTTPException(403, "רק מורה של הכיתה יכול/ה לנהל את המפגש")
629
+
630
+ @app.get("/api/live")
631
+ def live_list(user: dict | None = Depends(signed_in)):
632
+ if not user:
633
+ return {"sessions": [], "teaches": []}
634
+ mine = {c["id"] for c in user.get("classes", [])}
635
+ teaches = {c["id"] for c in user.get("teaches", [])}
636
+ sessions = app.state.live.list_for(mine | teaches, user["id"])
637
+ return {"sessions": [{**s, "teacher": s["class_id"] in teaches} for s in sessions],
638
+ "teaches": [{"id": c["id"], "name": c["name"], "lessons": lessons_of(c)}
639
+ for c in user.get("teaches", [])]}
640
+
641
+ @app.post("/api/live")
642
+ def live_start(body: dict = Body(...), user: dict | None = Depends(signed_in)):
643
+ try:
644
+ class_id = int(body.get("class_id"))
645
+ except (TypeError, ValueError):
646
+ raise HTTPException(400, "צריך לבחור כיתה")
647
+ course = live_course(class_id)
648
+ live_teacher(course, user)
649
+ return {"session": app.state.live.start(course, user)}
650
+
651
+ @app.post("/api/live/join")
652
+ def live_join(body: dict = Body(...), user: dict | None = Depends(signed_in)):
653
+ code = re.sub(r"\D", "", str(body.get("code", "")))
654
+ if len(code) != 6:
655
+ raise HTTPException(400, "קוד המפגש מכיל 6 ספרות")
656
+ session = app.state.live.get(code)
657
+ if not session:
658
+ raise HTTPException(404, "לא נמצא מפגש פעיל בקוד הזה")
659
+ course = live_course(session["class_id"])
660
+ email = user["email"].lower()
661
+ name = (course.get("nicknames") or {}).get(email, "")
662
+ try:
663
+ joined = app.state.live.join(code, user, name)
664
+ except KeyError:
665
+ raise HTTPException(404, "המפגש הסתיים")
666
+ return {"session": joined}
667
+
668
+ def live_session_teacher(code: str, user: dict | None) -> tuple[dict, dict]:
669
+ session = app.state.live.get(code)
670
+ if not session:
671
+ raise HTTPException(404, "המפגש הסתיים")
672
+ course = live_course(session["class_id"])
673
+ live_teacher(course, user)
674
+ return session, course
675
+
676
+ @app.get("/api/live/{code}")
677
+ def live_status(code: str, user: dict | None = Depends(signed_in)):
678
+ session = app.state.live.get(code)
679
+ if not session:
680
+ raise HTTPException(404, "המפגש הסתיים")
681
+ course = live_course(session["class_id"])
682
+ teacher = bool(user and user["email"].lower() in (course.get("teachers") or []))
683
+ try:
684
+ return {"session": app.state.live.heartbeat(code, user, teacher)}
685
+ except PermissionError:
686
+ raise HTTPException(403, "יש להצטרף למפגש תחילה")
687
+ except KeyError:
688
+ raise HTTPException(404, "המפגש הסתיים")
689
+
690
+ @app.post("/api/live/{code}/question")
691
+ def live_question(code: str, body: dict = Body(...), user: dict | None = Depends(signed_in)):
692
+ _session, course = live_session_teacher(code, user)
693
+ phrase_id = body.get("phrase_id")
694
+ if not isinstance(phrase_id, int):
695
+ raise HTTPException(400, "צריך לבחור משפט מהמאגר")
696
+ phrase = phrase_of(phrase_id)
697
+ if topics().get(phrase["cat"]) not in lessons_of(course):
698
+ raise HTTPException(403, "המשפט אינו בשיעורים של הכיתה")
699
+ return {"session": app.state.live.ask(code, {**phrase, "id": phrase_id})}
700
+
701
+ @app.post("/api/live/{code}/answer")
702
+ def live_answer(code: str, audio: UploadFile = File(...), user: dict | None = Depends(signed_in)):
703
+ try:
704
+ question = app.state.live.begin_answer(code, user["id"])
705
+ except LiveConflict:
706
+ raise HTTPException(409, "כבר ענית על השאלה הזו")
707
+ except PermissionError:
708
+ raise HTTPException(403, "יש להצטרף למפגש תחילה")
709
+ except KeyError:
710
+ raise HTTPException(404, "אין כרגע שאלה פעילה")
711
+ try:
712
+ data = audio.file.read(MAX_UPLOAD + 1)
713
+ if len(data) > MAX_UPLOAD:
714
+ raise HTTPException(413, "recording too long")
715
+ try:
716
+ wav = decode_audio(data)
717
+ except ValueError:
718
+ raise HTTPException(400, "could not decode audio")
719
+ if wav.size < SAMPLE_RATE * .3:
720
+ raise HTTPException(400, "לא נשמע מספיק דיבור. נסו שוב.")
721
+ phrase = phrase_of(question["phrase_id"])
722
+ refs = app.state.refs.get(question["phrase_id"], phrase) if app.state.refs else []
723
+ result = app.state.scorer.score(wav, phrase, refs)
724
+ if result.get("error"):
725
+ raise HTTPException(400, result.get("message") or "ההקלטה לא זוהתה")
726
+ compact = {"score": round(result["score"]), "unrelated": bool(result.get("unrelated")),
727
+ "expected_tr": result.get("variant", {}).get("tr") or phrase.get("tr", ""),
728
+ "heard_tr": " ".join(w.get("heard_tr") or "–" for w in result.get("words", [])),
729
+ "issues": result.get("issues", [])[:8]}
730
+ app.state.live.finish_answer(code, user["id"], compact)
731
+ return {"answer": compact, "result": result}
732
+ except Exception:
733
+ app.state.live.cancel_answer(code, user["id"])
734
+ raise
735
+
736
+ @app.delete("/api/live/{code}")
737
+ def live_end(code: str, user: dict | None = Depends(signed_in)):
738
+ live_session_teacher(code, user)
739
+ try:
740
+ app.state.live.end(code)
741
+ except KeyError:
742
+ raise HTTPException(404, "המפגש הסתיים")
743
+ return {"ok": True}
744
+
745
  @app.get("/api/progress")
746
  def progress(user: dict | None = Depends(signed_in)):
747
  """The student's best scores and compact practice totals, including this week's active days."""
 
1682
  if "answered 404" in str(e):
1683
  return ("הטבלה classes לא קיימת ב-Supabase. צרו אותה ב-SQL Editor (הפקודה ב-README); "
1684
  "אם כבר נוצרה, הריצו שם: notify pgrst, 'reload schema';")
1685
+ if any(col in str(e) for col in ("nicknames", "lessons", "homework", "'due'", "dailies", "subject", "teachers")): # a table made before them
1686
  return f"לטבלה classes חסרות עמודות. הריצו ב-SQL Editor של Supabase: {MIGRATE_CLASSES}"
1687
  return f"Supabase לא זמין: {e}"
1688
 
 
1703
  if not isinstance(lessons, list) or not lessons or not set(map(str, lessons)) <= set(ALL_LESSONS):
1704
  raise HTTPException(400, "בחרו לפחות שיעור אחד לכיתה")
1705
  lessons = [l for l in ALL_LESSONS if l in map(str, lessons)]
1706
+ teachers = body.get("teachers")
1707
+ if teachers is not None:
1708
+ if not isinstance(teachers, list) or not all(isinstance(e, str) for e in teachers):
1709
+ raise HTTPException(400, "רשימת המורים אינה תקינה")
1710
+ teachers = list(dict.fromkeys(e.strip().lower() for e in teachers if e.strip()))
1711
+ if not set(teachers) <= set(emails):
1712
+ raise HTTPException(400, "מורה של הכיתה צריך להיות אחד מהתלמידים הרשומים בה")
1713
  listed = body.get("homeworks")
1714
  if listed is None:
1715
  listed = [{"topic": body.get("homework"), "due": body.get("due")}]
 
1758
  if (id is None and hasattr(a, "access_for") and actor and "admin" in actor.get("roles", ())
1759
  and "developer" not in actor.get("roles", ()) and not subject):
1760
  raise HTTPException(409, "לא הוגדר מקצוע למנהל/ת. מפתח/ת צריך להגדיר אותו בניטור.")
1761
+ args = (id, name, emails, nicks, lessons, homeworks, dailies if dailies or had else None, subject)
1762
+ if teachers is None:
1763
+ a.save_class(*args)
1764
+ else:
1765
+ a.save_class(*args, teachers=teachers)
1766
  except ClassConflict:
1767
  raise HTTPException(409, f"כבר יש כיתה בשם {name}")
1768
  except KeyError:
static/index.html CHANGED
@@ -94,11 +94,13 @@ body:not(.home) .help,body.manage .help,body:not(.home) .guidetip,body.manage .g
94
  #nav{position:fixed;inset:auto 0 0 0;z-index:8;display:flex;justify-content:center;gap:4px;padding:6px 10px calc(6px + env(safe-area-inset-bottom));background:var(--surface);border-top:1px solid var(--line)}
95
  #nav button{position:relative;flex:1;max-width:160px;padding:10px 6px;border:0;border-radius:10px;background:none;color:var(--muted);font:700 14px/1.2 var(--f-body);cursor:pointer}
96
  #nav button[aria-current=page]{background:var(--olive-soft);color:var(--olive)}
 
97
  #nav button.promo{color:var(--orange);animation:refer-pulse 1.8s ease-in-out infinite}
98
  #nav button.promo::after{content:"🎁 הטבה";position:absolute;top:-11px;inset-inline-end:4px;padding:2px 6px;border-radius:999px;background:var(--orange);color:#fff;font:700 11px/1.35 var(--f-body);white-space:nowrap;box-shadow:0 2px 5px rgba(0,0,0,.2)}
99
  @keyframes refer-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--orange) 0%,transparent)}50%{box-shadow:0 0 0 6px color-mix(in srgb,var(--orange) 22%,transparent)}}
100
  @media (prefers-reduced-motion:reduce){#nav button.promo{animation:none}}
101
  body:not(.home) #nav,body.manage #nav,body:not(.home) #vids,body.manage #vids,body:not([data-view]) #vids,body[data-view] #path,body[data-view] #today,body[data-view] #toNow,body[data-view] .maphint{display:none}
 
102
  body.home:not(.manage){padding-bottom:84px}
103
  body.home:not(.manage) #toNow{bottom:76px} body.home:not(.manage) #logoutBtn{bottom:62px}
104
  #vids{display:flex;flex-direction:column;gap:6px;width:100%;max-width:460px;margin:8px auto 0}
@@ -1201,13 +1203,15 @@ body.quiet :is(#mic,#playMine,#driveBtn,.phone-btn),body.driving :is(#quietBtn,#
1201
  .prow[aria-current="true"]{background:var(--olive-soft)}
1202
  .prow .i{font-size:13px;font-weight:400;color:var(--muted);font-variant-numeric:tabular-nums}
1203
  .prow .none{font-size:13px;font-weight:400;color:var(--muted)}
1204
- /* Opening screen: the logo plays in (an olive hexagon traces itself, the knot blooms and turns into it, the name is written right to left; the animation lives in the logo's svg), then the start button appears; pressing it fades the screen out. */
1205
- #logo-splash{position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:28px;background:white;transition:opacity .35s ease,visibility .35s ease}
1206
  #logo-splash.done{opacity:0;visibility:hidden;pointer-events:none}
1207
- #logo-splash img{width:min(82vw,620px);height:auto}
1208
- #startBtn,#loginBtn,#roles button{font:400 24px/1 var(--f-display);padding:14px 34px;border-radius:14px;border:0;cursor:pointer;background:#4B6A2C;color:#fff;opacity:0;animation:start-in .45s ease-out 2.1s forwards}
 
1209
  /* Under the Google button: sign-in by a link sent to the email, for a student without a Google account. */
1210
  #mailForm{display:flex;flex-direction:column;align-items:center;gap:10px;width:min(82vw,340px);margin-top:-8px;opacity:0;animation:start-in .45s ease-out 2.1s forwards}
 
1211
  #mailForm span{font:400 14px/1 var(--f-body);color:#5B665C}
1212
  #mailForm input{width:100%;box-sizing:border-box;font:400 17px/1 var(--f-body);padding:11px 14px;border-radius:10px;border:1px solid #B9C2B4;background:#fff;color:#1C241D;text-align:center}
1213
  #mailForm button{font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
@@ -1228,6 +1232,8 @@ html[data-subject="math"] #roles :is(#libraryBtn,#manageBtn,#labBtn){background:
1228
  html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
1229
  @keyframes start-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
1230
  @media (prefers-reduced-motion:reduce){#startBtn,#loginBtn,#roles button,#mailForm{animation:none;opacity:1}#logo-splash{transition:none}}
 
 
1231
  </style>
1232
  </head>
1233
  <body class="home">
@@ -1278,8 +1284,9 @@ html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
1278
  <span class="reward-totals"><span class="reward-week" id="rewardWeek">0/4 השבוע</span><span class="reward-total" id="rewardTotal">0 ★</span></span>
1279
  </button>
1280
  <section id="vids" aria-live="polite"></section>
1281
- <nav id="nav" aria-label="ניווט"><button data-view="">מסלול</button><button data-view="verbs">פעלים</button><button data-view="games" hidden>משחקים</button><button data-view="refer">חבר מביא חבר</button></nav>
1282
  <nav class="path" id="path" aria-label="מסלול הלימוד"></nav>
 
1283
  <button id="toNow" hidden></button>
1284
 
1285
  <div class="wrap" id="practice">
@@ -1462,6 +1469,7 @@ html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
1462
  <fieldset class="lpick"><legend>סרטונים יומיים<small>הסרטונים של הרמות שהכיתה לומדת, מהספרייה שתחת „תוכן”. קבעו תאריך לסרטון, ומאותו יום התלמידים רואים אותו בכפתור „סרטון יומי” שמעל המסלול, והוא מסומן עד שצופים בו. סרטון בלי תאריך לא מוצג לכיתה.</small></legend><div class="dailies" id="editDailies"></div></fieldset>
1463
  <label>התלמידים<small>שורה לכל תלמיד: כתובת הדוא"ל שבה הוא נכנס, ואחריה כינוי אם רוצים. בכיתה רואים את הכינוי, או את השם מ-Google. תלמיד יכול להיות בכמה כיתות.</small>
1464
  <textarea id="editEmails" spellcheck="false" placeholder="dana@gmail.com דנה&#10;avi@gmail.com"></textarea></label>
 
1465
  <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>
1466
  </form>
1467
  </dialog>
@@ -2430,7 +2438,87 @@ function mgWin(){ // every pair found: the misbaha dances, beads rain, and the
2430
  }
2431
  function showView(view){ // "" is the path, which is laid out again: it was not while hidden
2432
  if(view) document.body.dataset.view=view; else delete document.body.dataset.view;
2433
- scrollTo(0,0); drawPath(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2434
  }
2435
  const referTab=$("nav").querySelector('[data-view="refer"]'), referSeenKey="ihki.referSeen.2027";
2436
  if(!store.get(referSeenKey,false)) referTab.classList.add("promo");
@@ -4911,6 +4999,8 @@ function drawClasses(classes){
4911
  fact("שיעורים",lessonsLine(ALL_LESSONS.filter(l=>c.lessons.includes(l.id))));
4912
  const mine=CLASS.students.filter(s=>c.name in s.classes);
4913
  fact("תלמידים",...(mine.length?[`${mine.length}: `,...mine.flatMap((s,k)=>{ const b=document.createElement("bdi"); b.textContent=s.classes[c.name]||s.name||s.email; return k?[", ",b]:[b]; })]:["עוד אין"]));
 
 
4914
  const hws=hwOf(c,ALL_CATS).map(h=>mark("",[h.c.name,lessonName(ALL_LESSONS.find(l=>l.id===h.c.lesson)),dueText(h.due).text].filter(Boolean).join(" · "),"div"));
4915
  fact("שיעורי בית",...(hws.length?hws:["אין"]));
4916
  const days=classDailies(c);
@@ -5333,9 +5423,21 @@ function dailyRows(box,levels,days){
5333
  }
5334
  const daysOf=(days,levels)=>DAILIES.filter(v=>levels.includes(v.level)&&days[v.id]).map(v=>({video:v.id,date:days[v.id]}));
5335
  const editLevels=()=>{ const on=[...$("editLessons").querySelectorAll(".chips :checked")].map(b=>b.value); return ALL_LEVELS.filter(v=>ALL_LESSONS.some(l=>l.level===v.id&&on.includes(l.id))).map(v=>v.id); };
5336
- let editing=null, editDays={};
 
 
 
 
 
 
 
 
 
 
 
5337
  function editClass(c){
5338
  editing=c;
 
5339
  $("editTitle").textContent=c?`עריכת ${c.name}`:"כיתה חדשה";
5340
  $("editName").value=c?c.name:"";
5341
  // 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
@@ -5357,7 +5459,7 @@ function editClass(c){
5357
  $("editHws").replaceChildren(); for(const h of hwOf(c,ALL_CATS)) hwRow(h.c.id,h.due);
5358
  $("editEmails").value=c?c.emails.map(e=>[e,c.nicknames&&c.nicknames[e]].filter(Boolean).join(" ")).join("\n"):"";
5359
  $("editSave").textContent=c?"שמירה":"פתיחת הכיתה"; $("editDelete").hidden=!c; $("editMsg").textContent="";
5360
- countStudents(); $("editDlg").showModal(); (c?$("editEmails"):$("editName")).focus();
5361
  }
5362
  /* The class's homeworks in its dialog: a row each, a lesson, a topic of it and its day, and a button to drop it.
5363
  "+" adds the last topic of the first ticked lesson that has no homework yet: its written homework, where it has one. */
@@ -5380,7 +5482,7 @@ $("editHwAdd").onclick=()=>{
5380
  $("editMsg").textContent=""; hwRow(last.id,"").focus();
5381
  };
5382
  function countStudents(){ const n=($("editEmails").value.match(/[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+/g)||[]).length; $("editCount").textContent=n?`${n} תלמידים`:""; }
5383
- $("editEmails").oninput=countStudents;
5384
  $("editClose").onclick=()=>$("editDlg").close();
5385
  async function sendClass(method,body){
5386
  const msg=$("editMsg"), buttons=$("editForm").querySelectorAll("button");
@@ -5401,7 +5503,7 @@ $("editForm").onsubmit=e=>{
5401
  const homeworks=hwRows();
5402
  if(homeworks.some(h=>!lessons.includes((ALL_CATS.find(c=>c.id===h.topic)||{}).lesson))){ $("editMsg").textContent="שיעורי הבית הם נושאים משיעורי הכיתה: סמנו את השיעור שלהם למעלה, או הסירו אותם."; return; }
5403
  if(new Set(homeworks.map(h=>h.topic)).size<homeworks.length){ $("editMsg").textContent="אותו נושא מופיע פעמיים בשיעורי הבית."; return; }
5404
- sendClass(editing?"PUT":"POST",{name:$("editName").value,emails:$("editEmails").value,lessons,homeworks,dailies:daysOf(editDays,editLevels())});
5405
  };
5406
  $("editDelete").onclick=()=>{
5407
  if(confirm(`למחוק את הכיתה ${editing.name}? התלמידים שלה לא יוכלו להיכנס עוד. ההקלטות וההתקדמות שלהם נשמרות.`)) sendClass("DELETE");
@@ -6547,7 +6649,7 @@ const ready=fetch("/api/phrases").then(r=>{ if(!r.ok) throw new Error(`phrases:
6547
  /* The opening screen stays until the start button is pressed. */
6548
  function enter(){
6549
  const splash=$("logo-splash");
6550
- $("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),400);
6551
  if(MY_CLASSES.length>1&&!MY_CLASS) askClass(); // in several classes and none practised in before: which one?
6552
  if(AUTH_URL&&!can("path.use")){ if(can("games.use")||can("tutor.use")) showView("games"); else if(can("library.view")) showView("library"); }
6553
  }
@@ -6609,7 +6711,7 @@ if(AUTH_URL){
6609
  else{ const last=MY_CLASSES.find(c=>c.name===store.get("ihki.class","")); if(last) useClass(last); } // the class practised in last
6610
  const management=can("management.access"), laboratory=can("lab.access"), library=can("library.view"), practice=can("practice.use")||can("path.use")||can("games.use")||can("tutor.use");
6611
  start.hidden=!ok||management||laboratory||library; roles.hidden=!management&&!laboratory&&!library; $("practiceBtn").hidden=!practice; $("libraryBtn").hidden=!library; $("manageBtn").hidden=$("toManage").hidden=!management; $("labBtn").hidden=!laboratory; $("mLab").hidden=!laboratory; denied.hidden=ok;
6612
- if(ok){ [...roles.querySelectorAll("button:not([hidden])"),start].find(x=>!x.hidden)?.focus({preventScroll:true}); loadProgress().then(()=>{ if(PHRASES.length&&$("wrap").inert) startCat(); }); loadContent(); loadWriting(); loadTutor(); }
6613
  }catch(e){ start.hidden=false; throw e; } },0); // a failure here still lets the student in, and shows under the logo
6614
  });
6615
  }
 
94
  #nav{position:fixed;inset:auto 0 0 0;z-index:8;display:flex;justify-content:center;gap:4px;padding:6px 10px calc(6px + env(safe-area-inset-bottom));background:var(--surface);border-top:1px solid var(--line)}
95
  #nav button{position:relative;flex:1;max-width:160px;padding:10px 6px;border:0;border-radius:10px;background:none;color:var(--muted);font:700 14px/1.2 var(--f-body);cursor:pointer}
96
  #nav button[aria-current=page]{background:var(--olive-soft);color:var(--olive)}
97
+ #nav button.live-on{color:var(--sumac)}#nav button.live-on::after{content:"";position:absolute;top:7px;inset-inline-end:12px;width:7px;height:7px;border-radius:50%;background:var(--sumac)}
98
  #nav button.promo{color:var(--orange);animation:refer-pulse 1.8s ease-in-out infinite}
99
  #nav button.promo::after{content:"🎁 הטבה";position:absolute;top:-11px;inset-inline-end:4px;padding:2px 6px;border-radius:999px;background:var(--orange);color:#fff;font:700 11px/1.35 var(--f-body);white-space:nowrap;box-shadow:0 2px 5px rgba(0,0,0,.2)}
100
  @keyframes refer-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--orange) 0%,transparent)}50%{box-shadow:0 0 0 6px color-mix(in srgb,var(--orange) 22%,transparent)}}
101
  @media (prefers-reduced-motion:reduce){#nav button.promo{animation:none}}
102
  body:not(.home) #nav,body.manage #nav,body:not(.home) #vids,body.manage #vids,body:not([data-view]) #vids,body[data-view] #path,body[data-view] #today,body[data-view] #toNow,body[data-view] .maphint{display:none}
103
+ body:not([data-view=live]) #liveView{display:none}.live-page{display:flex;flex-direction:column;gap:12px;padding-bottom:90px}.live-page>.card{padding:18px}.live-page h1,.live-page h2{margin:0;font-family:var(--f-display);font-weight:400}.live-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:10px}.live-class{display:flex;flex-direction:column;gap:7px;padding:14px;border:1px solid var(--line);border-radius:13px;background:var(--surface)}.live-class .acts{display:flex;gap:7px;margin-top:auto}.live-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--ok);margin-inline-end:6px}.live-code{font:700 24px/1 var(--f-body);letter-spacing:.12em;direction:ltr}.live-question{text-align:center;padding:24px!important}.live-question .prompt{font-size:30px;margin:14px 0}.live-question .waiting-ring{width:64px;height:64px;margin:16px auto;border:6px solid var(--olive-soft);border-top-color:var(--olive);border-radius:50%;animation:live-turn 1.4s linear infinite}@keyframes live-turn{to{transform:rotate(360deg)}}.live-mic{display:grid;place-items:center;width:86px;height:86px;margin:20px auto;border:0;border-radius:50%;background:var(--sumac);color:white;font-size:28px;box-shadow:0 7px 0 color-mix(in srgb,var(--sumac) 75%,#000)}.live-mic.recording{animation:live-pulse 1s infinite}@keyframes live-pulse{50%{transform:scale(.94)}}.live-result{max-width:560px;margin:14px auto 0;padding:14px;border-radius:12px;background:var(--bg);text-align:start}.live-result .score{font-size:38px}.live-people{display:flex;flex-direction:column}.live-person{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;padding:9px 0;border-top:1px solid var(--line)}.live-person small{display:block;color:var(--muted)}.live-person .online{color:var(--ok)}.live-person .offline{color:var(--muted)}.live-ask{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}.live-ask select,.live-ask input{grid-column:1}.live-ask button{grid-column:2;grid-row:1/3}.live-join{display:flex;gap:8px}.live-join input{direction:ltr;text-align:center;letter-spacing:.15em;max-width:180px}.teacher-picks{display:flex;flex-direction:column;gap:6px;margin-top:8px}.teacher-picks label{display:flex;align-items:center;gap:8px;padding:7px 9px;border:1px solid var(--line);border-radius:9px}.teacher-picks small{display:block;color:var(--muted)}
104
  body.home:not(.manage){padding-bottom:84px}
105
  body.home:not(.manage) #toNow{bottom:76px} body.home:not(.manage) #logoutBtn{bottom:62px}
106
  #vids{display:flex;flex-direction:column;gap:6px;width:100%;max-width:460px;margin:8px auto 0}
 
1203
  .prow[aria-current="true"]{background:var(--olive-soft)}
1204
  .prow .i{font-size:13px;font-weight:400;color:var(--muted);font-variant-numeric:tabular-nums}
1205
  .prow .none{font-size:13px;font-weight:400;color:var(--muted)}
1206
+ /* Opening screen: its fixed top padding prevents late auth controls from shifting the logo. */
1207
+ #logo-splash{position:fixed;inset:0;z-index:9999;display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:28px;box-sizing:border-box;padding:clamp(36px,14vh,120px) 16px 24px;overflow:auto;background:white;transition:opacity .65s ease,visibility .65s ease}
1208
  #logo-splash.done{opacity:0;visibility:hidden;pointer-events:none}
1209
+ #logo-splash img{display:block;flex:none;width:min(82vw,620px);height:auto;aspect-ratio:489/225}
1210
+ #startBtn,#loginBtn,#roles button{font:400 24px/1 var(--f-display);padding:14px 34px;border-radius:14px;border:0;cursor:pointer;background:#4B6A2C;color:#fff;opacity:0;animation:start-in .65s ease-out 2.1s forwards}
1211
+ html[data-subject="arabic"] :is(#startBtn,#loginBtn,#roles button){animation-delay:6.55s}
1212
  /* Under the Google button: sign-in by a link sent to the email, for a student without a Google account. */
1213
  #mailForm{display:flex;flex-direction:column;align-items:center;gap:10px;width:min(82vw,340px);margin-top:-8px;opacity:0;animation:start-in .45s ease-out 2.1s forwards}
1214
+ html[data-subject="arabic"] #mailForm{animation-delay:6.55s}
1215
  #mailForm span{font:400 14px/1 var(--f-body);color:#5B665C}
1216
  #mailForm input{width:100%;box-sizing:border-box;font:400 17px/1 var(--f-body);padding:11px 14px;border-radius:10px;border:1px solid #B9C2B4;background:#fff;color:#1C241D;text-align:center}
1217
  #mailForm button{font:600 16px/1 var(--f-body);padding:10px 22px;border-radius:10px;border:1px solid #4B6A2C;background:#fff;color:#4B6A2C;cursor:pointer}
 
1232
  html[data-subject="math"] #mailForm button{border-color:#2F5597;color:#2F5597}
1233
  @keyframes start-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
1234
  @media (prefers-reduced-motion:reduce){#startBtn,#loginBtn,#roles button,#mailForm{animation:none;opacity:1}#logo-splash{transition:none}}
1235
+ #wrap{opacity:1;transition:opacity .65s ease .1s}
1236
+ #wrap[inert]{opacity:0}
1237
  </style>
1238
  </head>
1239
  <body class="home">
 
1284
  <span class="reward-totals"><span class="reward-week" id="rewardWeek">0/4 השבוע</span><span class="reward-total" id="rewardTotal">0 ★</span></span>
1285
  </button>
1286
  <section id="vids" aria-live="polite"></section>
1287
+ <nav id="nav" aria-label="ניווט"><button data-view="">מסלול</button><button data-view="live" hidden>שיעור חי</button><button data-view="verbs">פעלים</button><button data-view="games" hidden>משחקים</button><button data-view="refer">חבר מביא חבר</button></nav>
1288
  <nav class="path" id="path" aria-label="מסלול הלימוד"></nav>
1289
+ <section class="live-page" id="liveView" aria-live="polite"></section>
1290
  <button id="toNow" hidden></button>
1291
 
1292
  <div class="wrap" id="practice">
 
1469
  <fieldset class="lpick"><legend>סרטונים יומיים<small>הסרטונים של הרמות שהכיתה לומדת, מהספרייה שתחת „תוכן”. קבעו תאריך לסרטון, ומאותו יום התלמידים רואים אותו בכפתור „סרטון יומי” שמעל המסלול, והוא מסומן עד שצופים בו. סרטון בלי תאריך לא מוצג לכיתה.</small></legend><div class="dailies" id="editDailies"></div></fieldset>
1470
  <label>התלמידים<small>שורה לכל תלמיד: כתובת הדוא"ל שבה הוא נכנס, ואחריה כינוי אם רוצים. בכיתה רואים את הכינוי, או את השם מ-Google. תלמיד יכול להיות בכמה כיתות.</small>
1471
  <textarea id="editEmails" spellcheck="false" placeholder="dana@gmail.com דנה&#10;avi@gmail.com"></textarea></label>
1472
+ <fieldset class="lpick"><legend>מורי הכיתה<small>בחרו אחד או יותר מתלמידי הכיתה. ההרשאה היא לכיתה הזו בלבד.</small></legend><div class="teacher-picks" id="editTeachers"></div></fieldset>
1473
  <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>
1474
  </form>
1475
  </dialog>
 
2438
  }
2439
  function showView(view){ // "" is the path, which is laid out again: it was not while hidden
2440
  if(view) document.body.dataset.view=view; else delete document.body.dataset.view;
2441
+ scrollTo(0,0); drawPath(true); if(view==="live") loadLive();
2442
+ }
2443
+ const LIVE_TAB_ENABLED=false; // Keep the finished live-class flow hidden until it is ready for release.
2444
+ let LIVE={sessions:[],teaches:[],code:"",timer:null,rec:null,busy:false};
2445
+ const liveEsc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
2446
+ const livePhrase=p=>`${liveEsc(p.he)} · ${liveEsc((ALL_CATS.find(c=>c.id===p.cat)||{}).name||"")}`;
2447
+ function liveSchedule(){ clearTimeout(LIVE.timer); if(document.body.dataset.view==="live"&&LIVE.code) LIVE.timer=setTimeout(()=>loadLiveSession(),3000); }
2448
+ async function loadLive(silent=false){
2449
+ try{
2450
+ const res=await api("/api/live"), data=await res.json(); if(!res.ok) throw new Error(data.detail||res.status);
2451
+ LIVE.sessions=data.sessions||[]; LIVE.teaches=data.teaches||[];
2452
+ const tab=$("nav").querySelector('[data-view="live"]'); tab.hidden=!LIVE_TAB_ENABLED; tab.classList.toggle("live-on",LIVE_TAB_ENABLED&&LIVE.sessions.length>0);
2453
+ if(!silent&&document.body.dataset.view==="live"&&!LIVE.code) drawLiveHome();
2454
+ }catch(err){ if(!silent) $("liveView").innerHTML=`<div class="card">השיעורים החיים אינם זמינים (${liveEsc(err.message)}).</div>`; }
2455
+ }
2456
+ function drawLiveHome(){
2457
+ clearTimeout(LIVE.timer); const box=$("liveView"), active=new Map(LIVE.sessions.map(s=>[s.class_id,s]));
2458
+ let html='<div class="card"><h1>שיעור חי</h1><p class="sub">הצטרפו למפגש פעיל של אחת הכיתות שלכם, או הכניסו קוד שקיבלתם ממורה.</p></div>';
2459
+ if(LIVE.teaches.length){ html+='<div class="card"><h2>הכיתות שאני מלמד/ת</h2><div class="live-grid">'+LIVE.teaches.map(c=>{const s=active.get(c.id);return `<div class="live-class"><b>${liveEsc(c.name)}</b><small>${s?'<span class="live-dot"></span>מפגש פעיל':'אין מפגש פעיל'}</small><div class="acts"><button class="btn primary" data-live-${s?'open':'start'}="${s?liveEsc(s.code):c.id}">${s?'כניסה למפגש':'פתיחת שיעור חי'}</button></div></div>`}).join('')+'</div></div>'; }
2460
+ const student=LIVE.sessions.filter(s=>!s.teacher);
2461
+ html+='<div class="card"><h2>שיעורים פעילים בכיתות שלי</h2>'+(student.length?'<div class="live-grid">'+student.map(s=>`<div class="live-class"><b>${liveEsc(s.class_name)}</b><small><span class="live-dot"></span>המפגש פתוח עכשיו</small><button class="btn primary" data-live-join="${liveEsc(s.code)}">הצטרפות</button></div>`).join('')+'</div>':'<p class="sub">אין כרגע שיעור חי בכיתות שלכם.</p>')+'</div>';
2462
+ html+='<div class="card"><h2>הצטרפות עם קוד</h2><p class="sub">אפשרות גיבוי למפגש שאינו ברשימת הכיתות שלכם.</p><form class="live-join" id="liveCodeForm"><input id="liveCode" inputmode="numeric" maxlength="6" pattern="[0-9]{6}" placeholder="000000" required aria-label="קוד מפגש"><button class="btn">הצטרפות</button></form><p class="msg" id="liveMsg"></p></div>';
2463
+ box.innerHTML=html;
2464
+ box.querySelectorAll("[data-live-start]").forEach(b=>b.onclick=()=>startLive(+b.dataset.liveStart));
2465
+ box.querySelectorAll("[data-live-open]").forEach(b=>b.onclick=()=>openLive(b.dataset.liveOpen,true));
2466
+ box.querySelectorAll("[data-live-join]").forEach(b=>b.onclick=()=>joinLive(b.dataset.liveJoin));
2467
+ $("liveCodeForm").onsubmit=e=>{e.preventDefault();joinLive($("liveCode").value)};
2468
+ }
2469
+ async function startLive(classId){
2470
+ const res=await api("/api/live",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({class_id:classId})}), data=await res.json().catch(()=>({}));
2471
+ if(!res.ok){ $("liveMsg").textContent=data.detail||`הפתיחה נכשלה (${res.status})`; return; }
2472
+ openLive(data.session.code,true);
2473
+ }
2474
+ async function joinLive(code){
2475
+ code=String(code).replace(/\D/g,""); const res=await api("/api/live/join",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code})}), data=await res.json().catch(()=>({}));
2476
+ if(!res.ok){ const msg=$("liveMsg"); if(msg) msg.textContent=data.detail||`ההצטרפות נכשלה (${res.status})`; return; }
2477
+ openLive(data.session.code,false);
2478
+ }
2479
+ function openLive(code,teacher){ LIVE.code=code; LIVE.teacher=teacher; loadLiveSession(); }
2480
+ async function loadLiveSession(){
2481
+ if(!LIVE.code||LIVE.busy) return liveSchedule();
2482
+ try{
2483
+ const res=await api(`/api/live/${LIVE.code}`), data=await res.json().catch(()=>({}));
2484
+ if(res.status===404){ LIVE.code=""; await loadLive(); return; }
2485
+ if(!res.ok) throw new Error(data.detail||res.status);
2486
+ if(LIVE.teacher) drawLiveTeacher(data.session); else drawLiveStudent(data.session);
2487
+ }catch(err){ $("liveView").innerHTML=`<div class="card">המפגש אינו זמין (${liveEsc(err.message)}). <button class="btn" id="liveBack">חזרה</button></div>`; $("liveBack").onclick=()=>{LIVE.code="";loadLive()}; }
2488
+ liveSchedule();
2489
+ }
2490
+ function eligibleLivePhrases(session){
2491
+ const course=LIVE.teaches.find(c=>c.id===session.class_id), lessons=new Set(course&&course.lessons||[]), cats=new Set(ALL_CATS.filter(c=>lessons.has(c.lesson)).map(c=>c.id));
2492
+ return PHRASES.filter(p=>cats.has(p.cat)&&p.listed!==false&&!p.read);
2493
+ }
2494
+ function drawLiveTeacher(s){
2495
+ const list=eligibleLivePhrases(s), answered=(s.participants||[]).filter(p=>p.answer).length;
2496
+ let html=`<div class="card"><button class="btn" id="liveBack">חזרה</button> <button class="btn danger" id="liveEnd">סיום המפגש</button><h1>${liveEsc(s.class_name)}</h1><p>קוד גיבוי: <b class="live-code">${liveEsc(s.code)}</b></p></div>`;
2497
+ html+=`<div class="card"><h2>שליחת שאלה</h2><p class="sub">בחרו משפט או הקלידו בעברית כדי לחפש במאגר של שיעורי הכיתה.</p><div class="live-ask"><input id="liveSearch" placeholder="חיפוש משפט בעברית"><select id="livePhrase">${list.map(p=>`<option value="${p.id}">${livePhrase(p)}</option>`).join('')}</select><button class="btn primary" id="liveAsk" ${list.length?'':'disabled'}>שליחה לכיתה</button></div><p class="msg" id="liveAskMsg"></p></div>`;
2498
+ if(s.question) html+=`<div class="card"><h2>השאלה הנוכחית</h2><p><b>${liveEsc(s.question.he)}</b></p><p class="sub">ענו ${answered} מתוך ${(s.participants||[]).length}</p></div>`;
2499
+ html+=`<div class="card"><h2>תלמידים במפגש</h2><div class="live-people">${(s.participants||[]).length?(s.participants||[]).map(p=>`<div class="live-person"><span><b>${liveEsc(p.name)}</b><small class="${p.online?'online':'offline'}">${p.online?'מחובר/ת עכשיו':'לא מחובר/ת'}</small>${p.answer?`<small>צריך: ${liveEsc(p.answer.expected_tr)}<br>נאמר: ${liveEsc(p.answer.heard_tr)}</small>`:''}</span><span>${p.answer?`<b class="pill">${p.answer.score}</b>`:'ממתינ/ה'}</span></div>`).join(''):'<p class="sub">עוד אין תלמידים במפגש.</p>'}</div></div>`;
2500
+ $("liveView").innerHTML=html; $("liveBack").onclick=()=>{LIVE.code="";loadLive()}; $("liveEnd").onclick=endLive;
2501
+ const search=$("liveSearch"), select=$("livePhrase"); search.oninput=()=>{ const q=search.value.trim(); select.replaceChildren(...list.filter(p=>!q||p.he.includes(q)).slice(0,100).map(p=>new Option(`${p.he} · ${(ALL_CATS.find(c=>c.id===p.cat)||{}).name||""}`,p.id))); $("liveAsk").disabled=!select.value; };
2502
+ $("liveAsk").onclick=async()=>{ const res=await api(`/api/live/${s.code}/question`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({phrase_id:+select.value})}), data=await res.json().catch(()=>({})); if(!res.ok) $("liveAskMsg").textContent=data.detail||res.status; else loadLiveSession(); };
2503
+ }
2504
+ async function endLive(){ if(!confirm("לסיים את המפגש? התוצאות הזמניות יימחקו."))return; await api(`/api/live/${LIVE.code}`,{method:"DELETE"}); LIVE.code=""; loadLive(); }
2505
+ function drawLiveStudent(s){
2506
+ let body;
2507
+ if(!s.question) body='<div class="waiting-ring"></div><h1>מחכים לשאלה</h1><p class="sub">אתם מחוברים. השאלה תופיע כאן כשהמורה ישלח אותה.</p>';
2508
+ else if(s.answer) body=`<h1>המשוב האישי שלך</h1><div class="live-result"><b class="score">${s.answer.score}</b><p><small>צריך</small><br><b>${liveEsc(s.answer.expected_tr)}</b></p><p><small>נאמר</small><br><b>${liveEsc(s.answer.heard_tr)}</b></p></div><p class="sub">ניסיון אחד בלבד. רק את/ה והמורה רואים את התוצאה.</p>`;
2509
+ else if(s.answered) body='<div class="waiting-ring"></div><h1>בודקים את ההגייה…</h1>';
2510
+ else body=`<small>${liveEsc(s.class_name)}</small><h1 class="prompt">${liveEsc(s.question.he)}</h1><p class="sub">ענו בערבית. התשובה אינה מוצגת לפני ההקלטה.</p><button class="live-mic" id="liveMic" aria-label="הקלטת תשובה"><svg class="ic" aria-hidden="true"><use href="#i-mic"/></svg></button><b id="liveStatus">לחצו ודברו</b>`;
2511
+ $("liveView").innerHTML=`<div class="card"><button class="btn" id="liveBack">יציאה</button></div><div class="card live-question">${body}</div>`; $("liveBack").onclick=()=>{LIVE.code="";loadLive()}; if($("liveMic")) $("liveMic").onclick=liveRecord;
2512
+ }
2513
+ async function liveRecord(){
2514
+ if(LIVE.rec&&LIVE.rec.state==="recording"){ LIVE.rec.stop(); return; }
2515
+ let stream; try{ stream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true}}); }catch{ $("liveStatus").textContent="אין גישה למיקרופון"; return; }
2516
+ const chunks=[], type=pickType(); LIVE.rec=new MediaRecorder(stream,type?{mimeType:type}:{}); LIVE.busy=true;
2517
+ LIVE.rec.ondataavailable=e=>{if(e.data.size)chunks.push(e.data)}; LIVE.rec.onstart=()=>{ $("liveMic").classList.add("recording"); $("liveStatus").textContent="מקליט… לחצו לסיום"; };
2518
+ LIVE.rec.onstop=async()=>{ stream.getTracks().forEach(t=>t.stop()); const blob=new Blob(chunks,{type:LIVE.rec.mimeType||"audio/webm"}), fd=new FormData(); fd.append("audio",blob,"live"); $("liveStatus").textContent="בודקים…";
2519
+ try{ const res=await api(`/api/live/${LIVE.code}/answer`,{method:"POST",body:fd}), data=await res.json().catch(()=>({})); if(!res.ok) throw new Error(data.detail||res.status); }catch(err){ $("liveStatus").textContent=`לא הצלחנו לבדוק: ${err.message}`; }
2520
+ LIVE.busy=false; LIVE.rec=null; loadLiveSession(); };
2521
+ LIVE.rec.start(); setTimeout(()=>LIVE.rec&&LIVE.rec.state==="recording"&&LIVE.rec.stop(),10000);
2522
  }
2523
  const referTab=$("nav").querySelector('[data-view="refer"]'), referSeenKey="ihki.referSeen.2027";
2524
  if(!store.get(referSeenKey,false)) referTab.classList.add("promo");
 
4999
  fact("שיעורים",lessonsLine(ALL_LESSONS.filter(l=>c.lessons.includes(l.id))));
5000
  const mine=CLASS.students.filter(s=>c.name in s.classes);
5001
  fact("תלמידים",...(mine.length?[`${mine.length}: `,...mine.flatMap((s,k)=>{ const b=document.createElement("bdi"); b.textContent=s.classes[c.name]||s.name||s.email; return k?[", ",b]:[b]; })]:["עוד אין"]));
5002
+ const teachers=(c.teachers||[]).map(e=>{ const s=mine.find(x=>x.email.toLowerCase()===e); return s&&(s.classes[c.name]||s.name)||e; });
5003
+ fact("מורים",teachers.length?teachers.join(" · "):"עוד לא נבחרו");
5004
  const hws=hwOf(c,ALL_CATS).map(h=>mark("",[h.c.name,lessonName(ALL_LESSONS.find(l=>l.id===h.c.lesson)),dueText(h.due).text].filter(Boolean).join(" · "),"div"));
5005
  fact("שיעורי בית",...(hws.length?hws:["אין"]));
5006
  const days=classDailies(c);
 
5423
  }
5424
  const daysOf=(days,levels)=>DAILIES.filter(v=>levels.includes(v.level)&&days[v.id]).map(v=>({video:v.id,date:days[v.id]}));
5425
  const editLevels=()=>{ const on=[...$("editLessons").querySelectorAll(".chips :checked")].map(b=>b.value); return ALL_LEVELS.filter(v=>ALL_LESSONS.some(l=>l.level===v.id&&on.includes(l.id))).map(v=>v.id); };
5426
+ let editing=null, editDays={}, editTeacherSet=new Set();
5427
+ function editTeacherChoices(){
5428
+ const box=$("editTeachers"), found=($("editEmails").value.match(/[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+/g)||[]).map(e=>e.toLowerCase());
5429
+ editTeacherSet=new Set([...editTeacherSet].filter(e=>found.includes(e))); box.replaceChildren();
5430
+ if(!found.length){ box.append(mark("sub","הוסיפו קודם תלמידים לכיתה.")); return; }
5431
+ for(const email of found){
5432
+ const row=document.createElement("label"), tick=document.createElement("input"), known=(CLASS.students||[]).find(s=>s.email.toLowerCase()===email), text=document.createElement("span");
5433
+ tick.type="checkbox"; tick.checked=editTeacherSet.has(email); tick.onchange=()=>tick.checked?editTeacherSet.add(email):editTeacherSet.delete(email);
5434
+ text.append(known&&(known.name||Object.values(known.classes||{}).find(Boolean))||email); if(known&&known.name){ const small=mark("",email,"small"); small.dir="ltr"; text.append(small); }
5435
+ row.append(tick,text); box.append(row);
5436
+ }
5437
+ }
5438
  function editClass(c){
5439
  editing=c;
5440
+ editTeacherSet=new Set(c&&c.teachers||[]);
5441
  $("editTitle").textContent=c?`עריכת ${c.name}`:"כיתה חדשה";
5442
  $("editName").value=c?c.name:"";
5443
  // 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
 
5459
  $("editHws").replaceChildren(); for(const h of hwOf(c,ALL_CATS)) hwRow(h.c.id,h.due);
5460
  $("editEmails").value=c?c.emails.map(e=>[e,c.nicknames&&c.nicknames[e]].filter(Boolean).join(" ")).join("\n"):"";
5461
  $("editSave").textContent=c?"שמירה":"פתיחת הכיתה"; $("editDelete").hidden=!c; $("editMsg").textContent="";
5462
+ countStudents(); editTeacherChoices(); $("editDlg").showModal(); (c?$("editEmails"):$("editName")).focus();
5463
  }
5464
  /* The class's homeworks in its dialog: a row each, a lesson, a topic of it and its day, and a button to drop it.
5465
  "+" adds the last topic of the first ticked lesson that has no homework yet: its written homework, where it has one. */
 
5482
  $("editMsg").textContent=""; hwRow(last.id,"").focus();
5483
  };
5484
  function countStudents(){ const n=($("editEmails").value.match(/[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]+/g)||[]).length; $("editCount").textContent=n?`${n} תלמידים`:""; }
5485
+ $("editEmails").oninput=()=>{ countStudents(); editTeacherChoices(); };
5486
  $("editClose").onclick=()=>$("editDlg").close();
5487
  async function sendClass(method,body){
5488
  const msg=$("editMsg"), buttons=$("editForm").querySelectorAll("button");
 
5503
  const homeworks=hwRows();
5504
  if(homeworks.some(h=>!lessons.includes((ALL_CATS.find(c=>c.id===h.topic)||{}).lesson))){ $("editMsg").textContent="שיעורי הבית הם נושאים משיעורי הכיתה: סמנו את השיעור שלהם למעלה, או הסירו אותם."; return; }
5505
  if(new Set(homeworks.map(h=>h.topic)).size<homeworks.length){ $("editMsg").textContent="אותו נושא מופיע פעמיים בשיעורי הבית."; return; }
5506
+ sendClass(editing?"PUT":"POST",{name:$("editName").value,emails:$("editEmails").value,teachers:[...editTeacherSet],lessons,homeworks,dailies:daysOf(editDays,editLevels())});
5507
  };
5508
  $("editDelete").onclick=()=>{
5509
  if(confirm(`למחוק את הכיתה ${editing.name}? התלמידים שלה לא יוכלו להיכנס עוד. ההקלטות וההתקדמות שלהם נשמרות.`)) sendClass("DELETE");
 
6649
  /* The opening screen stays until the start button is pressed. */
6650
  function enter(){
6651
  const splash=$("logo-splash");
6652
+ $("wrap").inert=false; splash.classList.add("done"); setTimeout(()=>splash.remove(),700);
6653
  if(MY_CLASSES.length>1&&!MY_CLASS) askClass(); // in several classes and none practised in before: which one?
6654
  if(AUTH_URL&&!can("path.use")){ if(can("games.use")||can("tutor.use")) showView("games"); else if(can("library.view")) showView("library"); }
6655
  }
 
6711
  else{ const last=MY_CLASSES.find(c=>c.name===store.get("ihki.class","")); if(last) useClass(last); } // the class practised in last
6712
  const management=can("management.access"), laboratory=can("lab.access"), library=can("library.view"), practice=can("practice.use")||can("path.use")||can("games.use")||can("tutor.use");
6713
  start.hidden=!ok||management||laboratory||library; roles.hidden=!management&&!laboratory&&!library; $("practiceBtn").hidden=!practice; $("libraryBtn").hidden=!library; $("manageBtn").hidden=$("toManage").hidden=!management; $("labBtn").hidden=!laboratory; $("mLab").hidden=!laboratory; denied.hidden=ok;
6714
+ if(ok){ $("nav").querySelector('[data-view="live"]').hidden=!LIVE_TAB_ENABLED; loadLive(true); [...roles.querySelectorAll("button:not([hidden])"),start].find(x=>!x.hidden)?.focus({preventScroll:true}); loadProgress().then(()=>{ if(PHRASES.length&&$("wrap").inert) startCat(); }); loadContent(); loadWriting(); loadTutor(); }
6715
  }catch(e){ start.hidden=false; throw e; } },0); // a failure here still lets the student in, and shows under the logo
6716
  });
6717
  }
static/logo-intro.svg CHANGED
static/service-worker.js CHANGED
@@ -1,11 +1,10 @@
1
- const CACHE = "ihki-shell-v7";
2
  const SHELL = [
3
  "/static/manifest.webmanifest",
4
  "/static/icons/icon-192.png?v=2",
5
  "/static/icons/icon-512.png?v=2",
6
  "/static/icons/icon-maskable-512.png?v=2",
7
  "/static/logo-intro.svg",
8
- "/static/logo-intro-alt.svg",
9
  "/static/logo-math.svg",
10
  "/static/assets/arabic-calligraphy-path.webp"
11
  ];
 
1
+ const CACHE = "ihki-shell-v17";
2
  const SHELL = [
3
  "/static/manifest.webmanifest",
4
  "/static/icons/icon-192.png?v=2",
5
  "/static/icons/icon-512.png?v=2",
6
  "/static/icons/icon-maskable-512.png?v=2",
7
  "/static/logo-intro.svg",
 
8
  "/static/logo-math.svg",
9
  "/static/assets/arabic-calligraphy-path.webp"
10
  ];
tests/test_live.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from app.live import LiveConflict, LiveStore
4
+
5
+
6
+ COURSE = {"id": 7, "name": "כיתה א", "emails": ["student@example.com"],
7
+ "nicknames": {"student@example.com": "דנה"}, "teachers": ["teacher@example.com"]}
8
+ TEACHER = {"id": "teacher-id", "email": "teacher@example.com"}
9
+ STUDENT = {"id": "student-id", "email": "student@example.com"}
10
+ PHRASE = {"id": 12, "he": "איפה היית?", "ar": "وين كنت؟", "tr": "וֵין כֻּנְת?"}
11
+
12
+
13
+ def test_live_session_one_attempt_and_private_student_view():
14
+ live = LiveStore()
15
+ session = live.start(COURSE, TEACHER)
16
+ assert len(session["code"]) == 6 and session["question"] is None
17
+ assert live.start(COURSE, TEACHER)["code"] == session["code"] # one active session per class
18
+
19
+ live.join(session["code"], STUDENT, "דנה")
20
+ live.ask(session["code"], PHRASE)
21
+ assert live.begin_answer(session["code"], STUDENT["id"])["phrase_id"] == 12
22
+ with pytest.raises(LiveConflict):
23
+ live.begin_answer(session["code"], STUDENT["id"])
24
+
25
+ answer = {"score": 81, "expected_tr": PHRASE["tr"], "heard_tr": "וֵין כּוּנְת?", "issues": []}
26
+ live.finish_answer(session["code"], STUDENT["id"], answer)
27
+ student = live.heartbeat(session["code"], STUDENT)
28
+ assert student["answered"] is True and student["answer"] == answer
29
+ assert "participants" not in student
30
+
31
+ teacher = live.heartbeat(session["code"], TEACHER, teacher=True)
32
+ assert teacher["participants"][0]["name"] == "דנה"
33
+ assert teacher["participants"][0]["answer"] == answer
34
+
35
+
36
+ def test_failed_recording_can_be_retried_and_end_erases_session():
37
+ live = LiveStore()
38
+ code = live.start(COURSE, TEACHER)["code"]
39
+ live.join(code, STUDENT)
40
+ live.ask(code, PHRASE)
41
+ live.begin_answer(code, STUDENT["id"])
42
+ live.cancel_answer(code, STUDENT["id"])
43
+ live.begin_answer(code, STUDENT["id"])
44
+ live.end(code)
45
+ assert live.get(code) is None
tests/test_scoring.py CHANGED
@@ -2107,7 +2107,7 @@ def test_each_class_topic_opens_on_its_own_screen():
2107
  with TestClient(create_app(scorer_for(emissions(["w"])))) as c:
2108
  for path in ("/", "/arabic"): # "/" stays Arabic
2109
  page = c.get(path).text
2110
- assert 'data-subject="arabic"' in page and "/static/logo-intro-alt.svg" in page and "<title>אהלא וסהלא</title>" in page
2111
  math = c.get("/math").text
2112
  assert 'data-subject="math"' in math and "/static/logo-math.svg" in math and "<title>מתמטיקה בכיף</title>" in math
2113
  assert "__SUBJECT__" not in math and "__START_LABEL__" not in math
 
2107
  with TestClient(create_app(scorer_for(emissions(["w"])))) as c:
2108
  for path in ("/", "/arabic"): # "/" stays Arabic
2109
  page = c.get(path).text
2110
+ assert 'data-subject="arabic"' in page and "/static/logo-intro.svg" in page and "<title>אהלא וסהלא</title>" in page
2111
  math = c.get("/math").text
2112
  assert 'data-subject="math"' in math and "/static/logo-math.svg" in math and "<title>מתמטיקה בכיף</title>" in math
2113
  assert "__SUBJECT__" not in math and "__START_LABEL__" not in math