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