feat: add class exam navigation
Browse filesAdditive migration stores exam schedules on classes. Unit subjects drive subject selection and route learners into the matching path.
- app/auth.py +3 -2
- app/main.py +2 -0
- app/math_algebra9.json +7 -0
- app/math_skills.py +2 -1
- docs/mathly-supabase.sql +4 -8
- static/math.css +90 -0
- static/math.js +197 -38
- supabase/migrations/20261031213000_add_class_exam_board.sql +8 -0
- tests/test_math_skills.py +1 -0
- tests/test_phone_browser.py +10 -1
app/auth.py
CHANGED
|
@@ -91,13 +91,14 @@ class SupabaseAuth:
|
|
| 91 |
if "answered 404" not in str(exc):
|
| 92 |
raise
|
| 93 |
log.warning("the classes table is missing; every account is allowed")
|
| 94 |
-
# During the database migration, ignore rows belonging to a removed product.
|
| 95 |
-
rows = [row for row in rows if not row.get("subject") or row.get("subject") == "מתמטיקה"]
|
| 96 |
for row in rows:
|
| 97 |
row["emails"] = [str(value).lower() for value in row.get("emails") or []]
|
| 98 |
row["teachers"] = [str(value).lower() for value in row.get("teachers") or []]
|
| 99 |
row["lessons"] = row.get("lessons") or []
|
| 100 |
row["nicknames"] = row.get("nicknames") or {}
|
|
|
|
|
|
|
|
|
|
| 101 |
with self._lock:
|
| 102 |
self._classes = (now + CLASSES_TTL, rows)
|
| 103 |
return rows
|
|
|
|
| 91 |
if "answered 404" not in str(exc):
|
| 92 |
raise
|
| 93 |
log.warning("the classes table is missing; every account is allowed")
|
|
|
|
|
|
|
| 94 |
for row in rows:
|
| 95 |
row["emails"] = [str(value).lower() for value in row.get("emails") or []]
|
| 96 |
row["teachers"] = [str(value).lower() for value in row.get("teachers") or []]
|
| 97 |
row["lessons"] = row.get("lessons") or []
|
| 98 |
row["nicknames"] = row.get("nicknames") or {}
|
| 99 |
+
row["exams"] = row.get("exams") or []
|
| 100 |
+
row["exam_note"] = str(row.get("exam_note") or "")
|
| 101 |
+
row["exam_title"] = str(row.get("exam_title") or "")
|
| 102 |
with self._lock:
|
| 103 |
self._classes = (now + CLASSES_TTL, rows)
|
| 104 |
return rows
|
app/main.py
CHANGED
|
@@ -104,6 +104,8 @@ def create_app(auth: SupabaseAuth | None = None) -> FastAPI:
|
|
| 104 |
"permissions": (user or {}).get("permissions", []),
|
| 105 |
"teacher": bool((user or {}).get("teacher")),
|
| 106 |
"classes": [{"id": row["id"], "name": row["name"], "lessons": units_of(row),
|
|
|
|
|
|
|
| 107 |
"teacher": email.lower() in (row.get("teachers") or [])} for row in mine]}
|
| 108 |
|
| 109 |
@app.get("/api/math/skills")
|
|
|
|
| 104 |
"permissions": (user or {}).get("permissions", []),
|
| 105 |
"teacher": bool((user or {}).get("teacher")),
|
| 106 |
"classes": [{"id": row["id"], "name": row["name"], "lessons": units_of(row),
|
| 107 |
+
"exams": row.get("exams") or [], "exam_note": row.get("exam_note") or "",
|
| 108 |
+
"exam_title": row.get("exam_title") or "",
|
| 109 |
"teacher": email.lower() in (row.get("teachers") or [])} for row in mine]}
|
| 110 |
|
| 111 |
@app.get("/api/math/skills")
|
app/math_algebra9.json
CHANGED
|
@@ -7,42 +7,49 @@
|
|
| 7 |
"units": [
|
| 8 |
{
|
| 9 |
"id": "f",
|
|
|
|
| 10 |
"name": "יסודות",
|
| 11 |
"source": "חזרה מכיתות ז׳–ח׳",
|
| 12 |
"when": ""
|
| 13 |
},
|
| 14 |
{
|
| 15 |
"id": "t",
|
|
|
|
| 16 |
"name": "טכניקה אלגברית",
|
| 17 |
"source": "פריסת ט׳",
|
| 18 |
"when": "ספטמבר–אוקטובר, ינואר"
|
| 19 |
},
|
| 20 |
{
|
| 21 |
"id": "n",
|
|
|
|
| 22 |
"name": "תכונות של פונקציה",
|
| 23 |
"source": "פריסת ט׳",
|
| 24 |
"when": "מרץ"
|
| 25 |
},
|
| 26 |
{
|
| 27 |
"id": "q",
|
|
|
|
| 28 |
"name": "פונקציה ומשוואה ריבועית",
|
| 29 |
"source": "פריסת ט׳",
|
| 30 |
"when": "נובמבר–פברואר"
|
| 31 |
},
|
| 32 |
{
|
| 33 |
"id": "p",
|
|
|
|
| 34 |
"name": "חזקות ושורשים",
|
| 35 |
"source": "פריסת ט׳",
|
| 36 |
"when": "מאי"
|
| 37 |
},
|
| 38 |
{
|
| 39 |
"id": "w",
|
|
|
|
| 40 |
"name": "שאלות מילוליות",
|
| 41 |
"source": "פריסת ט׳",
|
| 42 |
"when": "מאי"
|
| 43 |
},
|
| 44 |
{
|
| 45 |
"id": "g",
|
|
|
|
| 46 |
"name": "גאומטריה",
|
| 47 |
"source": "תרגול מקורי: שטחים ומשפט פיתגורס",
|
| 48 |
"when": ""
|
|
|
|
| 7 |
"units": [
|
| 8 |
{
|
| 9 |
"id": "f",
|
| 10 |
+
"subject": "מתמטיקה",
|
| 11 |
"name": "יסודות",
|
| 12 |
"source": "חזרה מכיתות ז׳–ח׳",
|
| 13 |
"when": ""
|
| 14 |
},
|
| 15 |
{
|
| 16 |
"id": "t",
|
| 17 |
+
"subject": "מתמטיקה",
|
| 18 |
"name": "טכניקה אלגברית",
|
| 19 |
"source": "פריסת ט׳",
|
| 20 |
"when": "ספטמבר–אוקטובר, ינואר"
|
| 21 |
},
|
| 22 |
{
|
| 23 |
"id": "n",
|
| 24 |
+
"subject": "מתמטיקה",
|
| 25 |
"name": "תכונות של פונקציה",
|
| 26 |
"source": "פריסת ט׳",
|
| 27 |
"when": "מרץ"
|
| 28 |
},
|
| 29 |
{
|
| 30 |
"id": "q",
|
| 31 |
+
"subject": "מתמטיקה",
|
| 32 |
"name": "פונקציה ומשוואה ריבועית",
|
| 33 |
"source": "פריסת ט׳",
|
| 34 |
"when": "נובמבר–פברואר"
|
| 35 |
},
|
| 36 |
{
|
| 37 |
"id": "p",
|
| 38 |
+
"subject": "מתמטיקה",
|
| 39 |
"name": "חזקות ושורשים",
|
| 40 |
"source": "פריסת ט׳",
|
| 41 |
"when": "מאי"
|
| 42 |
},
|
| 43 |
{
|
| 44 |
"id": "w",
|
| 45 |
+
"subject": "מתמטיקה",
|
| 46 |
"name": "שאלות מילוליות",
|
| 47 |
"source": "פריסת ט׳",
|
| 48 |
"when": "מאי"
|
| 49 |
},
|
| 50 |
{
|
| 51 |
"id": "g",
|
| 52 |
+
"subject": "מתמטיקה",
|
| 53 |
"name": "גאומטריה",
|
| 54 |
"source": "תרגול מקורי: שטחים ומשפט פיתגורס",
|
| 55 |
"when": ""
|
app/math_skills.py
CHANGED
|
@@ -32,7 +32,8 @@ def problems(graph: dict) -> list[str]:
|
|
| 32 |
units = {u["id"] for u in graph["units"]}
|
| 33 |
skills = {s["id"]: s for s in graph["skills"]}
|
| 34 |
mistakes = graph["mistakes"]
|
| 35 |
-
out = [f"{
|
|
|
|
| 36 |
out += [f"{s['id']}: unknown prerequisite {p}" for s in skills.values() for p in s["prereqs"] if p not in skills]
|
| 37 |
if out:
|
| 38 |
return out
|
|
|
|
| 32 |
units = {u["id"] for u in graph["units"]}
|
| 33 |
skills = {s["id"]: s for s in graph["skills"]}
|
| 34 |
mistakes = graph["mistakes"]
|
| 35 |
+
out = [f"{u['id']}: subject is required" for u in graph["units"] if not str(u.get("subject") or "").strip()]
|
| 36 |
+
out += [f"{s['id']}: unknown unit {s['unit']}" for s in skills.values() if s["unit"] not in units]
|
| 37 |
out += [f"{s['id']}: unknown prerequisite {p}" for s in skills.values() for p in s["prereqs"] if p not in skills]
|
| 38 |
if out:
|
| 39 |
return out
|
docs/mathly-supabase.sql
CHANGED
|
@@ -23,17 +23,13 @@ create table if not exists public.classes (
|
|
| 23 |
nicknames jsonb not null default '{}',
|
| 24 |
lessons text[] not null default '{}',
|
| 25 |
teachers text[] not null default '{}',
|
|
|
|
|
|
|
|
|
|
| 26 |
created_at timestamptz not null default now()
|
| 27 |
);
|
| 28 |
|
| 29 |
-
|
| 30 |
-
if exists (select 1 from information_schema.columns
|
| 31 |
-
where table_schema = 'public' and table_name = 'classes' and column_name = 'subject') then
|
| 32 |
-
delete from public.classes where subject is distinct from 'מתמטיקה';
|
| 33 |
-
end if;
|
| 34 |
-
end $$;
|
| 35 |
-
alter table public.classes drop column if exists subject,
|
| 36 |
-
drop column if exists homework,
|
| 37 |
drop column if exists due,
|
| 38 |
drop column if exists homeworks,
|
| 39 |
drop column if exists dailies;
|
|
|
|
| 23 |
nicknames jsonb not null default '{}',
|
| 24 |
lessons text[] not null default '{}',
|
| 25 |
teachers text[] not null default '{}',
|
| 26 |
+
exams jsonb not null default '[]',
|
| 27 |
+
exam_title text not null default '',
|
| 28 |
+
exam_note text not null default '',
|
| 29 |
created_at timestamptz not null default now()
|
| 30 |
);
|
| 31 |
|
| 32 |
+
alter table public.classes drop column if exists homework,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
drop column if exists due,
|
| 34 |
drop column if exists homeworks,
|
| 35 |
drop column if exists dailies;
|
static/math.css
CHANGED
|
@@ -29,6 +29,13 @@ html.math-on, html.math-on body{overflow:hidden}
|
|
| 29 |
.mx-btn{border:1px solid var(--mx-accent);background:transparent;color:var(--mx-accent);font:700 14px/1 var(--f-body);padding:9px 14px;border-radius:999px;cursor:pointer}
|
| 30 |
.mx-btn.solid{background:var(--mx-accent);color:var(--mx-on-accent)}
|
| 31 |
.mx-note{margin:0;color:var(--muted);font-size:14px}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
/* progress: a ring for the whole path, a bar per unit */
|
| 34 |
.mx-progress{display:flex;gap:16px;align-items:center;background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:14px}
|
|
@@ -153,10 +160,93 @@ html.math-on, html.math-on body{overflow:hidden}
|
|
| 153 |
.mx-scan-card{background:#141719;color:#f4f2ec;border:1px solid #485050;border-radius:18px;padding:24px;width:min(520px,100%);display:flex;flex-direction:column;gap:14px;line-height:1.6}
|
| 154 |
.mx-scan-card p{margin:0}.mx-scan-card [hidden]{display:none}.mx-scan-card small{color:#adb7b1}
|
| 155 |
.mx-management,.mx-class-editor form{display:flex;flex-direction:column;gap:16px}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
.mx-management select{display:block;width:100%;margin-top:8px;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--ink);font:inherit}
|
| 157 |
.mx-management fieldset{border:1px solid var(--line);border-radius:12px;padding:14px;display:grid;gap:12px}
|
| 158 |
.mx-management input{accent-color:var(--mx-accent)}
|
| 159 |
.mx-management button:disabled{opacity:.6;cursor:wait}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
.mx-grade{border:1px solid var(--line);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:12px}
|
| 161 |
.mx-grade h4,.mx-grade p{margin:0}.mx-grade-correct{border-color:var(--ok);background:var(--ok-bg)}
|
| 162 |
.mx-grade summary{cursor:pointer;color:var(--mx-accent)}.mx-grade details p{margin-top:10px}
|
|
|
|
| 29 |
.mx-btn{border:1px solid var(--mx-accent);background:transparent;color:var(--mx-accent);font:700 14px/1 var(--f-body);padding:9px 14px;border-radius:999px;cursor:pointer}
|
| 30 |
.mx-btn.solid{background:var(--mx-accent);color:var(--mx-on-accent)}
|
| 31 |
.mx-note{margin:0;color:var(--muted);font-size:14px}
|
| 32 |
+
.mx-subject-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
|
| 33 |
+
.mx-subject-card{--exam:var(--mx-accent);display:flex;align-items:center;gap:12px;min-width:0;padding:14px;border:1px solid color-mix(in srgb,var(--exam) 48%,var(--line));border-radius:16px;background:linear-gradient(130deg,color-mix(in srgb,var(--exam) 14%,var(--surface)),var(--surface));color:var(--ink);text-align:start;cursor:pointer}
|
| 34 |
+
.mx-subject-card:hover{border-color:var(--exam);transform:translateY(-1px)}
|
| 35 |
+
.mx-subject-icon{flex:none;width:46px;height:46px;border-radius:14px;display:grid;place-items:center;background:color-mix(in srgb,var(--exam) 22%,var(--bg));color:var(--exam);font:700 24px/1 var(--f-body)}
|
| 36 |
+
.mx-subject-card>span:nth-child(2){display:flex;flex:1;flex-direction:column;gap:4px;min-width:0}.mx-subject-card b{font-size:18px}.mx-subject-card small{color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mx-subject-card i{color:var(--exam);font-size:26px;font-style:normal}
|
| 37 |
+
.mx-subject-empty{--exam:var(--mx-accent);display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center;border:1px solid color-mix(in srgb,var(--exam) 45%,var(--line));border-radius:18px;background:var(--surface);padding:32px 20px}.mx-subject-empty>span{font-size:42px}.mx-subject-empty h2{color:var(--exam)}.mx-subject-empty p{max-width:480px;margin:0;color:var(--muted);line-height:1.5}
|
| 38 |
+
@media (max-width:520px){.mx-subject-grid{grid-template-columns:1fr}}
|
| 39 |
|
| 40 |
/* progress: a ring for the whole path, a bar per unit */
|
| 41 |
.mx-progress{display:flex;gap:16px;align-items:center;background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:14px}
|
|
|
|
| 160 |
.mx-scan-card{background:#141719;color:#f4f2ec;border:1px solid #485050;border-radius:18px;padding:24px;width:min(520px,100%);display:flex;flex-direction:column;gap:14px;line-height:1.6}
|
| 161 |
.mx-scan-card p{margin:0}.mx-scan-card [hidden]{display:none}.mx-scan-card small{color:#adb7b1}
|
| 162 |
.mx-management,.mx-class-editor form{display:flex;flex-direction:column;gap:16px}
|
| 163 |
+
.mx-manage-sheet{
|
| 164 |
+
inset:0;max-height:none;border-radius:0;box-shadow:none;gap:12px;
|
| 165 |
+
padding-top:max(18px,env(safe-area-inset-top,0px));
|
| 166 |
+
padding-bottom:max(28px,env(safe-area-inset-bottom,0px));
|
| 167 |
+
padding-left:max(18px,calc((100vw - 1100px)/2));
|
| 168 |
+
padding-right:max(18px,calc((100vw - 1100px)/2));
|
| 169 |
+
}
|
| 170 |
+
.mx-manage-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
| 171 |
+
.mx-manage-head h3{font-size:30px;color:var(--mx-accent)}
|
| 172 |
+
.mx-manage-spacer{flex:1}
|
| 173 |
+
.mx-manage-state{min-height:18px;margin:0;color:var(--muted);font-size:13px}
|
| 174 |
+
.mx-manage-modes{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px;padding:4px;border-radius:14px;background:var(--line)}
|
| 175 |
+
.mx-manage-modes button{display:flex;flex-direction:column;align-items:center;gap:3px;min-width:0;padding:10px 6px;border:0;border-radius:10px;background:transparent;color:var(--ink);font:700 16px/1.2 var(--f-body);cursor:pointer;text-align:center}
|
| 176 |
+
.mx-manage-modes button small{font:600 11px/1.25 var(--f-body);color:var(--muted)}
|
| 177 |
+
.mx-manage-modes button[aria-selected="true"]{background:var(--surface);box-shadow:0 1px 4px rgb(0 0 0/.35)}
|
| 178 |
+
.mx-manage-title{display:flex;align-items:baseline;justify-content:space-between;gap:8px;flex-wrap:wrap;border-bottom:1px solid var(--line);padding-bottom:10px}
|
| 179 |
+
.mx-manage-title h4{margin:0;font:400 23px/1.2 var(--f-display);color:var(--mx-accent)}
|
| 180 |
+
.mx-manage-title span{color:var(--muted);font-size:13px}
|
| 181 |
+
.mx-manage-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
| 182 |
+
.mx-manage-list article,.mx-manage-stats article,.mx-access-card{border:1px solid var(--line);border-radius:12px;background:var(--bg);padding:12px}
|
| 183 |
+
.mx-manage-list article{display:flex;flex-direction:column;gap:4px;min-width:0}
|
| 184 |
+
.mx-manage-list article b{overflow:hidden;text-overflow:ellipsis}
|
| 185 |
+
.mx-manage-list article small,.mx-manage-stats small,.mx-access-card>small{color:var(--muted)}
|
| 186 |
+
.mx-manage-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
|
| 187 |
+
.mx-manage-stats article{display:flex;flex-direction:column;gap:5px;text-align:center}
|
| 188 |
+
.mx-manage-stats b{font:400 28px/1 var(--f-display);color:var(--mx-accent)}
|
| 189 |
+
.mx-access-card{display:flex;flex-direction:column;gap:9px}
|
| 190 |
+
.mx-tag-row{display:flex;gap:6px;flex-wrap:wrap}
|
| 191 |
+
.mx-tag-row span{border:1px solid var(--mx-accent);border-radius:999px;padding:5px 9px;color:var(--mx-accent);font-size:12px}
|
| 192 |
+
.mx-class-toolbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
| 193 |
+
.mx-class-toolbar span{color:var(--muted);font-size:13px}
|
| 194 |
+
.mx-class-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
|
| 195 |
+
.mx-class-card,.mx-empty-class{border:1px solid var(--line);border-radius:14px;background:var(--bg);padding:16px}
|
| 196 |
+
.mx-class-card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}
|
| 197 |
+
.mx-class-card h5,.mx-empty-class h5{margin:0;font:400 22px/1.25 var(--f-display);color:var(--ink)}
|
| 198 |
+
.mx-class-actions{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}
|
| 199 |
+
.mx-btn.danger{border-color:var(--sumac);color:var(--sumac)}
|
| 200 |
+
.mx-class-card dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 12px;margin:0;font-size:14px}
|
| 201 |
+
.mx-class-card dt{color:var(--muted)}
|
| 202 |
+
.mx-class-card dd{margin:0;min-width:0;overflow-wrap:anywhere}
|
| 203 |
+
.mx-empty-class p{margin:7px 0 0;color:var(--muted)}
|
| 204 |
+
.mx-class-form{display:flex;flex-direction:column;gap:16px}
|
| 205 |
+
.mx-class-form>label{display:flex;flex-direction:column;gap:6px;font-weight:700}
|
| 206 |
+
.mx-class-form label>small,.mx-class-form legend small{display:block;color:var(--muted);font-size:12px;font-weight:500;margin-top:3px}
|
| 207 |
+
.mx-class-form input[type="text"],.mx-class-form input[name="name"],.mx-class-form textarea{box-sizing:border-box;width:100%;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink);padding:11px 12px;font:inherit}
|
| 208 |
+
.mx-class-form textarea{min-height:110px;resize:vertical;text-align:left}
|
| 209 |
+
.mx-class-form fieldset{margin:0}
|
| 210 |
+
.mx-unit-picks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
| 211 |
+
.mx-unit-picks label{display:flex;align-items:center;gap:9px;border:1px solid var(--line);border-radius:11px;padding:10px;cursor:pointer}
|
| 212 |
+
.mx-unit-picks label:has(input:checked){border-color:var(--mx-accent);background:var(--mx-accent-soft)}
|
| 213 |
+
.mx-unit-picks label span{display:flex;flex-direction:column;gap:2px;font-weight:700}
|
| 214 |
+
.mx-unit-picks label small{color:var(--muted);font-size:11px;font-weight:500}
|
| 215 |
+
.mx-class-form-actions{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
|
| 216 |
+
.mx-class-form-actions [role="status"]{color:var(--muted);font-size:13px}
|
| 217 |
+
.mx-exam-board{border:1px solid var(--line);border-radius:16px;background:var(--surface);padding:16px;display:flex;flex-direction:column;gap:14px}
|
| 218 |
+
.mx-class-card .mx-exam-board{margin-top:16px;background:var(--surface);padding:13px}
|
| 219 |
+
.mx-exam-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
|
| 220 |
+
.mx-exam-head h2,.mx-exam-head h4{margin:0;color:var(--mx-accent);font:400 22px/1.25 var(--f-display)}
|
| 221 |
+
.mx-exam-head small{color:var(--muted)}
|
| 222 |
+
.mx-exam-countdown{--exam:#7FA6F0;display:flex;align-items:center;gap:14px;border:1px solid color-mix(in srgb,var(--exam) 55%,var(--line));border-radius:16px;padding:13px 16px;background:linear-gradient(115deg,color-mix(in srgb,var(--exam) 20%,var(--surface)),var(--surface) 58%)}
|
| 223 |
+
.mx-exam-countdown>span{flex:none;width:76px;height:76px;border-radius:50%;display:grid;place-content:center;text-align:center;background:radial-gradient(closest-side,var(--surface) 72%,transparent 74%),conic-gradient(var(--exam) 82%,var(--line) 0);box-shadow:0 0 24px color-mix(in srgb,var(--exam) 20%,transparent)}
|
| 224 |
+
.mx-exam-countdown>span b{font:400 24px/1 var(--f-display);color:var(--exam)}.mx-exam-countdown>span small{font-size:10px;color:var(--muted)}
|
| 225 |
+
.mx-exam-countdown>div{display:flex;flex-direction:column;gap:3px;min-width:0}.mx-exam-countdown>div>small{color:var(--muted);font-size:12px}.mx-exam-countdown strong{font-size:20px}.mx-exam-countdown time{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 8px;color:var(--ink);font-size:17px;font-weight:800}.mx-exam-countdown time>span{flex-basis:100%;color:var(--exam);font-size:12px;font-weight:700}.mx-exam-countdown time>small{direction:ltr;unicode-bidi:isolate;color:var(--muted);font-size:11px;font-weight:600}
|
| 226 |
+
.mx-exam-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
|
| 227 |
+
.mx-exam-card{--exam:#7FA6F0;display:flex;align-items:center;gap:9px;min-width:0;border:1px solid color-mix(in srgb,var(--exam) 45%,var(--line));border-radius:12px;background:color-mix(in srgb,var(--exam) 10%,var(--bg));padding:10px}
|
| 228 |
+
.mx-exam-card.is-next{outline:2px solid var(--exam);outline-offset:1px}
|
| 229 |
+
.mx-exam-icon{flex:none;width:34px;height:34px;display:grid;place-items:center;border-radius:10px;background:color-mix(in srgb,var(--exam) 22%,var(--bg));color:var(--exam);font:700 19px/1 var(--f-body)}
|
| 230 |
+
.mx-exam-card div{display:flex;flex-direction:column;gap:2px;min-width:0}.mx-exam-card b{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mx-exam-card small{color:var(--muted);font-size:12px}
|
| 231 |
+
.exam-geo{--exam:#65B58A}.exam-civics{--exam:#B89BEB}.exam-intro{--exam:#E0B45A}.exam-math{--exam:#7FA6F0}.exam-language{--exam:#D98AAE}.exam-finance{--exam:#93B85F}.exam-science{--exam:#5CC2B2}.exam-english{--exam:#6FB3D2}.exam-literature{--exam:#C99870}.exam-history{--exam:#A3AD9F}.exam-bible{--exam:#D79B62}
|
| 232 |
+
.mx-exam-months{display:flex;flex-direction:column;gap:16px}.mx-exam-months section{display:flex;flex-direction:column;gap:8px}.mx-exam-months h5{margin:0;font:400 18px/1.2 var(--f-display);color:var(--ink)}
|
| 233 |
+
.mx-exam-board details summary{cursor:pointer;color:var(--mx-accent);font-weight:700}.mx-exam-board details[open] summary{margin-bottom:14px}
|
| 234 |
+
.mx-exam-note{margin:0;color:var(--muted);font-size:13px}
|
| 235 |
.mx-management select{display:block;width:100%;margin-top:8px;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--ink);font:inherit}
|
| 236 |
.mx-management fieldset{border:1px solid var(--line);border-radius:12px;padding:14px;display:grid;gap:12px}
|
| 237 |
.mx-management input{accent-color:var(--mx-accent)}
|
| 238 |
.mx-management button:disabled{opacity:.6;cursor:wait}
|
| 239 |
+
@media (max-width:620px){
|
| 240 |
+
.mx-manage-modes{grid-template-columns:repeat(2,minmax(0,1fr))}
|
| 241 |
+
.mx-manage-list{grid-template-columns:1fr}
|
| 242 |
+
.mx-manage-stats{grid-template-columns:1fr}
|
| 243 |
+
.mx-class-cards,.mx-unit-picks{grid-template-columns:1fr}
|
| 244 |
+
.mx-exam-grid{grid-template-columns:1fr 1fr}
|
| 245 |
+
.mx-class-card-head{flex-direction:column}
|
| 246 |
+
.mx-manage-head .mx-btn{padding-inline:10px}
|
| 247 |
+
}
|
| 248 |
+
@media (max-width:400px){.mx-exam-grid{grid-template-columns:1fr}}
|
| 249 |
+
@media (prefers-reduced-motion:no-preference){.mx-manage-sheet{animation:none}}
|
| 250 |
.mx-grade{border:1px solid var(--line);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:12px}
|
| 251 |
.mx-grade h4,.mx-grade p{margin:0}.mx-grade-correct{border-color:var(--ok);background:var(--ok-bg)}
|
| 252 |
.mx-grade summary{cursor:pointer;color:var(--mx-accent)}.mx-grade details p{margin-top:10px}
|
static/math.js
CHANGED
|
@@ -23,7 +23,7 @@ const key=()=>"mathly.progress.v1:"+(ME_EMAIL||"guest");
|
|
| 23 |
const load=()=>{ try{ return JSON.parse(localStorage.getItem(key())||"{}"); }catch{ return {}; } };
|
| 24 |
const save=()=>{ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} };
|
| 25 |
|
| 26 |
-
let G=null, BY={}, POST={}, DEPTH={}, ORDER=[], P={done:{},tries:{}}, root=null, path=null, sheet=null, cur=null, back=null;
|
| 27 |
let picker=null, PH=null; // the photo file picker, and the photographed problem on the sheet (see photo())
|
| 28 |
let advanceTimer=null;
|
| 29 |
let phonePair=null;
|
|
@@ -70,7 +70,8 @@ async function show(){
|
|
| 70 |
try{ const r=await fetch("/api/math/skills"); if(!r.ok) throw new Error(`השרת ענה ${r.status}`); index(await r.json()); }
|
| 71 |
catch(e){ path.innerHTML=`<p class="mx-note">לא הצלחנו לטעון את המסלול: ${esc(e.message)}. רעננו את העמוד ונסו שוב.</p>`; return; }
|
| 72 |
}
|
| 73 |
-
P=Object.assign({done:{},tries:{}},load()); SHOWN=new Set(shownUnits().map(u=>u.id));
|
|
|
|
| 74 |
window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,onGraded:({questionId,grading})=>{
|
| 75 |
if(grading.result.verdict!=="correct"||P.done[questionId])return;
|
| 76 |
P.done[questionId]=grading.finished_at||new Date().toISOString();
|
|
@@ -79,11 +80,37 @@ async function show(){
|
|
| 79 |
}
|
| 80 |
function hide(){ cancelAdvance(); if(root) root.hidden=true; document.documentElement.classList.remove("math-on"); }
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
function draw(){
|
| 83 |
const UNITS=G.units.filter(u=>SHOWN.has(u.id)), mine=G.skills.filter(s=>SHOWN.has(s.unit));
|
| 84 |
const total=mine.length, doneN=mine.filter(s=>P.done[s.id]).length, nxt=next();
|
| 85 |
const cls=typeof MY_CLASS!=="undefined"&&MY_CLASS?` · כיתת ${esc(MY_CLASS.name)}`:"";
|
| 86 |
const manage=can("management.access"), pct=Math.round(100*doneN/total);
|
|
|
|
| 87 |
const units=UNITS.map(u=>{
|
| 88 |
const ids=ORDER.filter(id=>BY[id].unit===u.id), d=ids.filter(id=>P.done[id]).length;
|
| 89 |
return `<div class="mx-su u-${u.id}"><b>${esc(u.name)}</b><span class="mx-bar"><i style="width:${Math.round(100*d/ids.length)}%"></i></span><small>${d} מתוך ${ids.length}</small></div>`;
|
|
@@ -99,10 +126,11 @@ function draw(){
|
|
| 99 |
}).join("")}</div></section>`;
|
| 100 |
}).join("");
|
| 101 |
path.innerHTML=`<header class="mx-top"><div><h1>המסלול שלי</h1><p class="mx-sub">אלגברה וגאומטריה כיתה ט׳ · 5 יח״ל${cls}</p></div>
|
| 102 |
-
<div class="mx-acts">${manage?'<button class="mx-btn" data-act="manage">ניהול</button>':""}${AUTH_URL?'<button class="mx-btn" data-act="out">יציאה</button>':""}</div></header>
|
| 103 |
<section class="mx-progress" aria-label="ההתקדמות שלי">
|
| 104 |
<div class="mx-ring" style="--p:${pct}"><b>${doneN}</b><small>מתוך ${total}</small></div>
|
| 105 |
<div class="mx-units">${units}</div></section>
|
|
|
|
| 106 |
${nxt?`<button class="mx-next" data-id="${nxt}"><small>ממשיכים מכאן</small><b>${fmt(BY[nxt].name)}</b></button>`:'<p class="mx-note">סיימתם את כל המסלול. כל הכבוד!</p>'}
|
| 107 |
<button class="mx-photo" data-act="photo"><b>צילום תרגיל</b><small>צלמו תרגיל מהמחברת או מהדף, ונעזור לכם לפתור אותו צעד אחר צעד</small></button>
|
| 108 |
<button class="mx-photo" data-act="phone"><b>צילום בטלפון, פתרון במחשב</b><small>סרקו קוד בטלפון, צלמו תרגיל וראו את התשובה כאן אוטומטית</small></button>
|
|
@@ -175,9 +203,16 @@ function answer(btn){
|
|
| 175 |
function onClick(e){
|
| 176 |
const t=e.target.closest("button,[data-act]"); if(!t||!root.contains(t)) return;
|
| 177 |
if(t.dataset.choice!=null) return answer(t);
|
|
|
|
| 178 |
if(t.dataset.id) return open(t.dataset.id);
|
|
|
|
|
|
|
| 179 |
switch(t.dataset.act){
|
| 180 |
case "manage": return manageClasses();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
case "phone": return pairPhone();
|
| 182 |
case "copy-phone": return copyPhone(t);
|
| 183 |
case "photo": case "retake": return picker.click();
|
|
@@ -189,49 +224,173 @@ function onClick(e){
|
|
| 189 |
}
|
| 190 |
}
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
async function manageClasses(){
|
| 193 |
if(!can("management.access")) return;
|
| 194 |
closeSheet(); back=document.activeElement; cur="management";
|
| 195 |
sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
|
| 196 |
-
<div class="mx-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
|
| 197 |
-
<div class="mx-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
sheet.hidden=false; sheet.querySelector(".mx-x").focus();
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
try{
|
| 202 |
const response=await api("/api/classes");
|
| 203 |
if(!response.ok) throw new Error("לא הצלחנו לטעון את הכיתות. נסו שוב.");
|
| 204 |
const {classes}=await response.json();
|
| 205 |
-
if(!
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
}
|
| 236 |
|
| 237 |
/* A photographed problem (/api/math/photo, app/math_photo.py): hints first. The sheet shows the problem as it was
|
|
@@ -407,7 +566,7 @@ async function boot(){
|
|
| 407 |
const response=await api("/api/me");
|
| 408 |
if(!response.ok){ state.className="error"; state.textContent=response.status===403?"החשבון אינו משויך לכיתת מתמטיקה.":"לא ניתן להשלים את הכניסה."; return; }
|
| 409 |
const me=await response.json();CFG.userId=me.user_id;CFG.answerScans=me.answer_scans; ME_EMAIL=me.email||""; MY_CLASSES=me.classes||[]; MY_CLASS=MY_CLASSES.length===1?MY_CLASSES[0]:null;
|
| 410 |
-
CFG.permissions=me.permissions||[];
|
| 411 |
}
|
| 412 |
document.getElementById("boot").hidden=true;
|
| 413 |
show();
|
|
|
|
| 23 |
const load=()=>{ try{ return JSON.parse(localStorage.getItem(key())||"{}"); }catch{ return {}; } };
|
| 24 |
const save=()=>{ try{ localStorage.setItem(key(),JSON.stringify(P)); }catch{} };
|
| 25 |
|
| 26 |
+
let G=null, BY={}, POST={}, DEPTH={}, ORDER=[], P={done:{},tries:{}}, root=null, path=null, sheet=null, cur=null, back=null, ACTIVE_SUBJECT="";
|
| 27 |
let picker=null, PH=null; // the photo file picker, and the photographed problem on the sheet (see photo())
|
| 28 |
let advanceTimer=null;
|
| 29 |
let phonePair=null;
|
|
|
|
| 70 |
try{ const r=await fetch("/api/math/skills"); if(!r.ok) throw new Error(`השרת ענה ${r.status}`); index(await r.json()); }
|
| 71 |
catch(e){ path.innerHTML=`<p class="mx-note">לא הצלחנו לטעון את המסלול: ${esc(e.message)}. רעננו את העמוד ונסו שוב.</p>`; return; }
|
| 72 |
}
|
| 73 |
+
P=Object.assign({done:{},tries:{}},load()); SHOWN=new Set(shownUnits().map(u=>u.id));
|
| 74 |
+
subjectOptions().length?drawSubjectPicker():selectSubject("מתמטיקה");
|
| 75 |
window.MathlyAnswers?.configure({api,account:CFG.userId,enabled:CFG.answerScans,root,onGraded:({questionId,grading})=>{
|
| 76 |
if(grading.result.verdict!=="correct"||P.done[questionId])return;
|
| 77 |
P.done[questionId]=grading.finished_at||new Date().toISOString();
|
|
|
|
| 80 |
}
|
| 81 |
function hide(){ cancelAdvance(); if(root) root.hidden=true; document.documentElement.classList.remove("math-on"); }
|
| 82 |
|
| 83 |
+
function subjectOptions(){
|
| 84 |
+
const bySubject=new Map();
|
| 85 |
+
MY_CLASSES.forEach(row=>(row.exams||[]).forEach(exam=>{
|
| 86 |
+
if(!exam?.subject||!exam?.date)return;
|
| 87 |
+
const item=bySubject.get(exam.subject)||{name:exam.subject,dates:[],classes:new Set()};
|
| 88 |
+
item.dates.push(exam.date);item.classes.add(row.name);bySubject.set(exam.subject,item);
|
| 89 |
+
}));
|
| 90 |
+
return [...bySubject.values()].map(item=>({...item,dates:item.dates.sort()})).sort((a,b)=>a.dates[0].localeCompare(b.dates[0]));
|
| 91 |
+
}
|
| 92 |
+
function drawSubjectPicker(){
|
| 93 |
+
ACTIVE_SUBJECT="";
|
| 94 |
+
const manage=can("management.access"), subjects=subjectOptions(), today=new Date();today.setHours(0,0,0,0);
|
| 95 |
+
path.innerHTML=`<header class="mx-top"><div><h1>המקצועות שלי</h1><p class="mx-sub">בחרו מקצוע כדי לפתוח את המסלול שלו</p></div><div class="mx-acts">${manage?'<button class="mx-btn" data-act="manage">ניהול</button>':""}${AUTH_URL?'<button class="mx-btn" data-act="out">יציאה</button>':""}</div></header>
|
| 96 |
+
<section class="mx-subject-grid" aria-label="מקצועות הכיתה">${subjects.map(item=>{
|
| 97 |
+
const [icon,tone]=examLooks[item.name]||["●","other"],future=item.dates.find(date=>examDate(date)>=today),count=item.dates.length;
|
| 98 |
+
return `<button class="mx-subject-card exam-${tone}" data-subject="${esc(item.name)}"><span class="mx-subject-icon" aria-hidden="true">${icon}</span><span><b>${esc(item.name)}</b><small>${count} מבחנים${future?` · הבא ${examDay(future)}`:" · הסתיימו"}</small></span><i aria-hidden="true">‹</i></button>`;
|
| 99 |
+
}).join("")}</section>`;
|
| 100 |
+
}
|
| 101 |
+
function selectSubject(subject){
|
| 102 |
+
ACTIVE_SUBJECT=subject;
|
| 103 |
+
if(subject==="מתמטיקה")return draw();
|
| 104 |
+
const [icon,tone]=examLooks[subject]||["●","other"];
|
| 105 |
+
path.innerHTML=`<header class="mx-top"><div><h1>${esc(subject)}</h1><p class="mx-sub">המקצועות שלי</p></div><button class="mx-btn" data-act="subjects">לכל המקצועות</button></header><section class="mx-subject-empty exam-${tone}"><span aria-hidden="true">${icon}</span><h2>המסלול של ${esc(subject)} עדיין לא זמין</h2><p>לוח המבחנים של המקצוע כבר מחובר לכיתה. כשהמסלול יתווסף, הוא ייפתח מכאן.</p><button class="mx-btn solid" data-act="subjects">חזרה למקצועות</button></section>`;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
function draw(){
|
| 109 |
const UNITS=G.units.filter(u=>SHOWN.has(u.id)), mine=G.skills.filter(s=>SHOWN.has(s.unit));
|
| 110 |
const total=mine.length, doneN=mine.filter(s=>P.done[s.id]).length, nxt=next();
|
| 111 |
const cls=typeof MY_CLASS!=="undefined"&&MY_CLASS?` · כיתת ${esc(MY_CLASS.name)}`:"";
|
| 112 |
const manage=can("management.access"), pct=Math.round(100*doneN/total);
|
| 113 |
+
const examRows=(MY_CLASS?[MY_CLASS]:MY_CLASSES).filter(row=>(row.exams||[]).length);
|
| 114 |
const units=UNITS.map(u=>{
|
| 115 |
const ids=ORDER.filter(id=>BY[id].unit===u.id), d=ids.filter(id=>P.done[id]).length;
|
| 116 |
return `<div class="mx-su u-${u.id}"><b>${esc(u.name)}</b><span class="mx-bar"><i style="width:${Math.round(100*d/ids.length)}%"></i></span><small>${d} מתוך ${ids.length}</small></div>`;
|
|
|
|
| 126 |
}).join("")}</div></section>`;
|
| 127 |
}).join("");
|
| 128 |
path.innerHTML=`<header class="mx-top"><div><h1>המסלול שלי</h1><p class="mx-sub">אלגברה וגאומטריה כיתה ט׳ · 5 יח״ל${cls}</p></div>
|
| 129 |
+
<div class="mx-acts">${subjectOptions().length?'<button class="mx-btn" data-act="subjects">מקצועות</button>':""}${manage?'<button class="mx-btn" data-act="manage">ניהול</button>':""}${AUTH_URL?'<button class="mx-btn" data-act="out">יציאה</button>':""}</div></header>
|
| 130 |
<section class="mx-progress" aria-label="ההתקדמות שלי">
|
| 131 |
<div class="mx-ring" style="--p:${pct}"><b>${doneN}</b><small>מתוך ${total}</small></div>
|
| 132 |
<div class="mx-units">${units}</div></section>
|
| 133 |
+
${examRows.map(row=>examBoard(row,true)).join("")}
|
| 134 |
${nxt?`<button class="mx-next" data-id="${nxt}"><small>ממשיכים מכאן</small><b>${fmt(BY[nxt].name)}</b></button>`:'<p class="mx-note">סיימתם את כל המסלול. כל הכבוד!</p>'}
|
| 135 |
<button class="mx-photo" data-act="photo"><b>צילום תרגיל</b><small>צלמו תרגיל מהמחברת או מהדף, ונעזור לכם לפתור אותו צעד אחר צעד</small></button>
|
| 136 |
<button class="mx-photo" data-act="phone"><b>צילום בטלפון, פתרון במחשב</b><small>סרקו קוד בטלפון, צלמו תרגיל וראו את התשובה כאן אוטומטית</small></button>
|
|
|
|
| 203 |
function onClick(e){
|
| 204 |
const t=e.target.closest("button,[data-act]"); if(!t||!root.contains(t)) return;
|
| 205 |
if(t.dataset.choice!=null) return answer(t);
|
| 206 |
+
if(t.dataset.subject) return selectSubject(t.dataset.subject);
|
| 207 |
if(t.dataset.id) return open(t.dataset.id);
|
| 208 |
+
if(t.dataset.manageMode) return showManageMode(t.dataset.manageMode);
|
| 209 |
+
if(t.dataset.classAction) return manageClassAction(t.dataset.classAction,t.dataset.classId);
|
| 210 |
switch(t.dataset.act){
|
| 211 |
case "manage": return manageClasses();
|
| 212 |
+
case "manage-refresh": return loadManagedClasses(true);
|
| 213 |
+
case "manage-lab": return showManageMode("lab");
|
| 214 |
+
case "manage-practice": return closeSheet();
|
| 215 |
+
case "subjects": return drawSubjectPicker();
|
| 216 |
case "phone": return pairPhone();
|
| 217 |
case "copy-phone": return copyPhone(t);
|
| 218 |
case "photo": case "retake": return picker.click();
|
|
|
|
| 224 |
}
|
| 225 |
}
|
| 226 |
|
| 227 |
+
let MANAGED_CLASSES=[], MANAGE_MODE="classes", MANAGE_BUSY=false;
|
| 228 |
+
const manageTabs=[
|
| 229 |
+
["classes","כיתות","פתיחה, עריכה ומחיקה"],
|
| 230 |
+
["students","תלמידים","רשימה ומסנן כיתות"],
|
| 231 |
+
["content","תוכן","נושאים, סרטונים וטקסטים"],
|
| 232 |
+
["flags","ניטור","פעילות ודיווחי ציונים"],
|
| 233 |
+
["access","הרשאות","תפקידים וגישה"]
|
| 234 |
+
];
|
| 235 |
+
|
| 236 |
+
const examLooks={
|
| 237 |
+
"גיאוגרפיה":["🌍","geo"],"אזרחות":["🏛️","civics"],"מבוא מדעי":["🧪","intro"],
|
| 238 |
+
"מתמטיקה":["∑","math"],"לשון":["✍️","language"],"חינוך פיננסי":["₪","finance"],
|
| 239 |
+
"מדעים":["🔬","science"],"אנגלית":["A","english"],"ספרות":["📚","literature"],
|
| 240 |
+
"היסטוריה":["⌛","history"],"תנ״ך":["📜","bible"]
|
| 241 |
+
};
|
| 242 |
+
const examDate=value=>{const [y,m,d]=String(value).split("-").map(Number);return new Date(y,m-1,d)};
|
| 243 |
+
const examDay=value=>{const date=examDate(value);return `${String(date.getDate()).padStart(2,"0")}.${String(date.getMonth()+1).padStart(2,"0")}.${date.getFullYear()}`};
|
| 244 |
+
const examFullDate=value=>new Intl.DateTimeFormat("he-IL",{weekday:"long",day:"numeric",month:"long",year:"numeric"}).format(examDate(value));
|
| 245 |
+
const examMonth=value=>new Intl.DateTimeFormat("he-IL",{month:"long",year:"numeric"}).format(examDate(value));
|
| 246 |
+
const classSubjects=row=>[...new Set(G.units.filter(unit=>(row.lessons||[]).includes(unit.id)).map(unit=>unit.subject).filter(Boolean))];
|
| 247 |
+
function examCards(exams,nextDate){
|
| 248 |
+
return exams.map(exam=>{
|
| 249 |
+
const [icon,tone]=examLooks[exam.subject]||["●","other"], next=exam.date===nextDate;
|
| 250 |
+
return `<article class="mx-exam-card exam-${tone}${next?" is-next":""}"><span class="mx-exam-icon" aria-hidden="true">${icon}</span><div><b>${esc(exam.subject)}</b><small>${examDay(exam.date)}${next?" · המבחן הבא":""}</small></div></article>`;
|
| 251 |
+
}).join("");
|
| 252 |
+
}
|
| 253 |
+
function examBoard(row,compact=false){
|
| 254 |
+
const all=[...(row.exams||[])].filter(exam=>exam?.date&&exam?.subject).sort((a,b)=>a.date.localeCompare(b.date));
|
| 255 |
+
const subjects=classSubjects(row), exams=compact?all.filter(exam=>subjects.includes(exam.subject)):all;
|
| 256 |
+
if(!exams.length)return "";
|
| 257 |
+
const today=new Date();today.setHours(0,0,0,0);
|
| 258 |
+
const upcoming=exams.filter(exam=>examDate(exam.date)>=today), nextDate=upcoming[0]?.date||"";
|
| 259 |
+
const groups=[];exams.forEach(exam=>{const month=examMonth(exam.date),last=groups[groups.length-1];if(last?.month===month)last.exams.push(exam);else groups.push({month,exams:[exam]});});
|
| 260 |
+
const full=`<div class="mx-exam-months">${groups.map(group=>`<section><h5>${esc(group.month)}</h5><div class="mx-exam-grid">${examCards(group.exams,nextDate)}</div></section>`).join("")}</div>`;
|
| 261 |
+
const title=esc(compact?`מבחני ${subjects.join(" ו")}`:row.exam_title||`לוח מבחנים – ${row.name}`),note=esc(row.exam_note||"");
|
| 262 |
+
if(!compact)return `<section class="mx-exam-board"><div class="mx-exam-head"><div><h4>${title}</h4><small>${exams.length} מבחנים</small></div></div>${full}${note?`<p class="mx-exam-note">${note}</p>`:""}</section>`;
|
| 263 |
+
const next=upcoming[0], days=next?Math.max(0,Math.ceil((examDate(next.date)-today)/86400000)):null;
|
| 264 |
+
const countdown=next?`<div class="mx-exam-countdown exam-${(examLooks[next.subject]||["","other"])[1]}"><span><b>${days===0?"היום":days===1?"מחר":days}</b><small>${days>1?"ימים למבחן":""}</small></span><div><small>המבחן הקרוב</small><strong>${esc(next.subject)}</strong><time datetime="${esc(next.date)}"><span>מועד המבחן</span>${esc(examFullDate(next.date))}<small>${examDay(next.date)}</small></time></div></div>`:"";
|
| 265 |
+
return `<section class="mx-exam-board compact"><div class="mx-exam-head"><div><h2>${title}</h2><small>${upcoming.length?`${upcoming.length} מבחנים בהמשך השנה`:"לוח המבחנים הסתיים"}</small></div></div>${countdown}${upcoming.length?`<div class="mx-exam-grid">${examCards(upcoming,nextDate)}</div>`:""}${note?`<p class="mx-exam-note">${note}</p>`:""}</section>`;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
async function manageClasses(){
|
| 269 |
if(!can("management.access")) return;
|
| 270 |
closeSheet(); back=document.activeElement; cur="management";
|
| 271 |
sheet.innerHTML=`<div class="mx-scrim" data-act="close"></div>
|
| 272 |
+
<div class="mx-sheet mx-manage-sheet" role="dialog" aria-modal="true" aria-labelledby="mxTitle">
|
| 273 |
+
<div class="mx-manage-head"><h3 id="mxTitle">ניהול</h3><span class="mx-manage-spacer"></span>
|
| 274 |
+
<button class="mx-btn" data-act="manage-refresh">רענון</button>
|
| 275 |
+
<button class="mx-btn" data-act="manage-lab">מעבדה</button>
|
| 276 |
+
<button class="mx-btn solid" data-act="manage-practice">לתרגול</button>
|
| 277 |
+
<button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
|
| 278 |
+
<p class="mx-manage-state" aria-live="polite"></p>
|
| 279 |
+
<div class="mx-manage-modes" role="tablist" aria-label="ניהול">${manageTabs.map(([mode,title,sub])=>`<button role="tab" data-manage-mode="${mode}">${title}<small>${sub}</small></button>`).join("")}</div>
|
| 280 |
+
<div class="mx-management"></div></div>`;
|
| 281 |
sheet.hidden=false; sheet.querySelector(".mx-x").focus();
|
| 282 |
+
MANAGE_MODE="classes";
|
| 283 |
+
await loadManagedClasses();
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
async function loadManagedClasses(refresh=false){
|
| 287 |
+
if(MANAGE_BUSY||cur!=="management") return;
|
| 288 |
+
const state=sheet.querySelector(".mx-manage-state");
|
| 289 |
+
MANAGE_BUSY=true; state.textContent=refresh?"מרעננים את נתוני הניהול…":"טוענים את הכיתות…";
|
| 290 |
try{
|
| 291 |
const response=await api("/api/classes");
|
| 292 |
if(!response.ok) throw new Error("לא הצלחנו לטעון את הכיתות. נסו שוב.");
|
| 293 |
const {classes}=await response.json();
|
| 294 |
+
if(cur!=="management") return;
|
| 295 |
+
MANAGED_CLASSES=classes||[]; state.textContent=refresh?"הנתונים עודכנו.":""; showManageMode(MANAGE_MODE);
|
| 296 |
+
}catch(error){state.textContent=error.message;sheet.querySelector(".mx-management").innerHTML=`<p class="mx-note">${esc(error.message)}</p>`;}
|
| 297 |
+
finally{MANAGE_BUSY=false;}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
function showManageMode(mode){
|
| 301 |
+
if(cur!=="management") return;
|
| 302 |
+
MANAGE_MODE=mode;
|
| 303 |
+
sheet.querySelectorAll("[data-manage-mode]").forEach(button=>button.setAttribute("aria-selected",String(button.dataset.manageMode===mode)));
|
| 304 |
+
const box=sheet.querySelector(".mx-management");
|
| 305 |
+
if(mode==="classes") return drawManagedClasses(box);
|
| 306 |
+
if(mode==="students"){
|
| 307 |
+
const students=new Map();
|
| 308 |
+
MANAGED_CLASSES.forEach(row=>(row.emails||[]).forEach(email=>students.set(email,[...(students.get(email)||[]),row.name])));
|
| 309 |
+
box.innerHTML=`<div class="mx-manage-title"><h4>תלמידים</h4><span>${students.size} תלמידים · ${MANAGED_CLASSES.length} כיתות</span></div>${students.size?`<div class="mx-manage-list">${[...students].map(([email,classes])=>`<article><b dir="ltr">${esc(email)}</b><small>${esc(classes.join(" · "))}</small></article>`).join("")}</div>`:'<p class="mx-note">עדיין לא נוספו תלמידים לכיתות.</p>'}`;
|
| 310 |
+
return;
|
| 311 |
+
}
|
| 312 |
+
if(mode==="content"){
|
| 313 |
+
box.innerHTML=`<div class="mx-manage-title"><h4>תוכן לימודי</h4><span>${G.units.length} נושאים במסלול</span></div><div class="mx-manage-list">${G.units.map(unit=>{const skills=G.skills.filter(skill=>skill.unit===unit.id);const count=MANAGED_CLASSES.filter(row=>(row.lessons||[]).includes(unit.id)).length;return `<article class="u-${unit.id}"><b>${esc(unit.name)}</b><small>${skills.length} מיומנויות · פעיל ב־${count} כיתות</small></article>`}).join("")}</div>`;
|
| 314 |
+
return;
|
| 315 |
+
}
|
| 316 |
+
if(mode==="flags"){
|
| 317 |
+
const done=Object.keys(P.done||{}).length, tries=Object.values(P.tries||{}).reduce((sum,n)=>sum+(+n||0),0);
|
| 318 |
+
box.innerHTML=`<div class="mx-manage-title"><h4>ניטור</h4><span>פעילות ודיווחי ציונים</span></div><div class="mx-manage-stats"><article><b>${MANAGED_CLASSES.length}</b><small>כיתות פעילות</small></article><article><b>${done}</b><small>מיומנויות שהושלמו במכשיר זה</small></article><article><b>${tries}</b><small>ניסיונות במכשיר זה</small></article></div><p class="mx-note">נתוני התקדמות כיתתיים יופיעו כאן כאשר שירות דיווחי הציונים יחובר.</p>`;
|
| 319 |
+
return;
|
| 320 |
+
}
|
| 321 |
+
if(mode==="access"){
|
| 322 |
+
const roles=CFG.roles||[], permissions=CFG.permissions||[];
|
| 323 |
+
box.innerHTML=`<div class="mx-manage-title"><h4>הרשאות</h4><span>תפקידים וגישה</span></div><div class="mx-access-card"><small>התפקידים שלך</small><div class="mx-tag-row">${roles.length?roles.map(role=>`<span>${esc(role)}</span>`).join(""):'<span>מנהל/ת</span>'}</div><small>הרשאות פעילות</small><div class="mx-tag-row">${permissions.map(permission=>`<span dir="ltr">${esc(permission)}</span>`).join("")}</div></div>`;
|
| 324 |
+
return;
|
| 325 |
+
}
|
| 326 |
+
box.innerHTML='<div class="mx-manage-title"><h4>מעבדה</h4><span>כלים לבדיקה ופיתוח</span></div><p class="mx-note">כלי המעבדה למתמטיקה יתווספו כאן. אפשר לחזור לכל אזור ניהול מהכרטיסיות למעלה.</p>';
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
function drawManagedClasses(box){
|
| 330 |
+
const classes=MANAGED_CLASSES;
|
| 331 |
+
box.innerHTML=`<div class="mx-manage-title"><h4>כיתות</h4><span>פתיחה, עריכה ומחיקה</span></div>
|
| 332 |
+
<div class="mx-class-toolbar"><button class="mx-btn solid" data-class-action="new">+ כיתה חדשה</button><span>${classes.length} כיתות</span></div>
|
| 333 |
+
${classes.length?`<div class="mx-class-cards">${classes.map(row=>{
|
| 334 |
+
const units=G.units.filter(unit=>(row.lessons||[]).includes(unit.id));
|
| 335 |
+
const subjects=classSubjects(row);
|
| 336 |
+
const unitLine=units.length===G.units.length?`כל היחידות (${G.units.length})`:units.map(unit=>unit.name).join(" · ")||"לא נבחרו";
|
| 337 |
+
const students=row.emails||[], teachers=row.teachers||[];
|
| 338 |
+
return `<article class="mx-class-card"><div class="mx-class-card-head"><h5>${esc(row.name)}</h5><div class="mx-class-actions"><button class="mx-btn" data-class-action="edit" data-class-id="${esc(row.id)}">עריכה</button><button class="mx-btn danger" data-class-action="delete" data-class-id="${esc(row.id)}">מחיקה</button></div></div>
|
| 339 |
+
<dl><dt>יחידות</dt><dd>${esc(unitLine)}</dd><dt>מקצועות היחידות</dt><dd>${esc(subjects.join(" · ")||"לא הוגדרו")}</dd><dt>תלמידים</dt><dd>${students.length?`${students.length}: ${esc(students.join(" · "))}`:"עוד אין"}</dd><dt>מורים</dt><dd>${teachers.length?esc(teachers.join(" · ")):"עוד לא נבחרו"}</dd></dl>${examBoard(row)}</article>`;
|
| 340 |
+
}).join("")}</div>`:'<div class="mx-empty-class"><h5>עוד אין כיתות</h5><p>פתחו כיתה ראשונה, בחרו את יחידות הלימוד והוסיפו את כתובות התלמידים.</p></div>'}`;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
function manageClassAction(action,id){
|
| 344 |
+
if(action==="new") return openClassEditor(null);
|
| 345 |
+
if(action==="cancel") return drawManagedClasses(sheet.querySelector(".mx-management"));
|
| 346 |
+
const row=MANAGED_CLASSES.find(item=>String(item.id)===String(id));
|
| 347 |
+
if(!row) return;
|
| 348 |
+
if(action==="edit") return openClassEditor(row);
|
| 349 |
+
if(action==="delete") return deleteManagedClass(row);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
function openClassEditor(row){
|
| 353 |
+
const box=sheet.querySelector(".mx-management"), creating=!row;
|
| 354 |
+
const selected=new Set(row?.lessons||G.units.map(unit=>unit.id));
|
| 355 |
+
box.innerHTML=`<div class="mx-manage-title"><h4>${creating?"כיתה חדשה":`עריכת ${esc(row.name)}`}</h4><button class="mx-btn" type="button" data-class-action="cancel">חזרה לכיתות</button></div>
|
| 356 |
+
<form class="mx-class-form"><label>שם הכיתה<input name="name" required maxlength="80" autocomplete="off" placeholder="למשל: ט׳ 1" value="${esc(row?.name||"")}"></label>
|
| 357 |
+
<fieldset><legend>יחידות הלימוד<small>התלמידים יראו במסלול רק את המיומנויות של היחידות שסומנו. מקצוע היחידה קובע אילו מבחנים יוצגו במסלול.</small></legend><div class="mx-unit-picks">${G.units.map(unit=>`<label class="u-${unit.id}"><input type="checkbox" name="unit" value="${esc(unit.id)}"${selected.has(unit.id)?" checked":""}><span>${esc(unit.name)}<small>${esc(unit.subject)} · ${G.skills.filter(skill=>skill.unit===unit.id).length} מיומנויות</small></span></label>`).join("")}</div></fieldset>
|
| 358 |
+
<label>התלמידים<small>כתובת דוא״ל אחת בכל שורה. תלמיד יכול להשתייך ליותר מכיתה אחת.</small><textarea name="emails" dir="ltr" spellcheck="false" placeholder="student@example.com">${esc((row?.emails||[]).join("\n"))}</textarea></label>
|
| 359 |
+
<label>מורי הכיתה<small>כתובת דוא״ל אחת בכל שורה. למורים תהיה גישה לכיתה הזו.</small><textarea name="teachers" dir="ltr" spellcheck="false" placeholder="teacher@example.com">${esc((row?.teachers||[]).join("\n"))}</textarea></label>
|
| 360 |
+
<div class="mx-class-form-actions"><button class="mx-btn solid" type="submit">${creating?"פתיחת הכיתה":"שמירת השינויים"}</button><button class="mx-btn" type="button" data-class-action="cancel">ביטול</button><span role="status" aria-live="polite"></span></div></form>`;
|
| 361 |
+
const form=box.querySelector("form"), message=form.querySelector('[role="status"]');
|
| 362 |
+
form.querySelector('input[name="name"]').focus();
|
| 363 |
+
form.onsubmit=async event=>{
|
| 364 |
+
event.preventDefault();
|
| 365 |
+
const lessons=[...form.querySelectorAll('input[name="unit"]:checked')].map(input=>input.value);
|
| 366 |
+
if(!lessons.length){message.textContent="בחרו לפחות יחידת לימוד אחת.";return;}
|
| 367 |
+
const lines=name=>form.elements[name].value.split(/[\n,;]+/).map(value=>value.trim().toLowerCase()).filter(Boolean);
|
| 368 |
+
const body={name:form.elements.name.value.trim(),emails:lines("emails"),teachers:lines("teachers"),lessons};
|
| 369 |
+
const buttons=form.querySelectorAll("button"); buttons.forEach(button=>button.disabled=true); message.textContent=creating?"פותחים את הכיתה…":"שומרים…";
|
| 370 |
+
try{
|
| 371 |
+
const response=await api(creating?"/api/classes":`/api/classes/${encodeURIComponent(row.id)}`,{method:creating?"POST":"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
|
| 372 |
+
if(!response.ok){const detail=(await response.json().catch(()=>({}))).detail;throw new Error(detail||"לא הצלחנו לשמור את הכיתה. נסו שוב.");}
|
| 373 |
+
if(!creating){
|
| 374 |
+
Object.assign(row,body); MY_CLASSES.forEach(item=>{if(String(item.id)===String(row.id))Object.assign(item,body);});
|
| 375 |
+
if(String(MY_CLASS?.id)===String(row.id))Object.assign(MY_CLASS,body);
|
| 376 |
+
SHOWN=new Set(shownUnits().map(unit=>unit.id)); draw();
|
| 377 |
+
}
|
| 378 |
+
await loadManagedClasses(true);
|
| 379 |
+
}catch(error){message.textContent=error.message;buttons.forEach(button=>button.disabled=false);}
|
| 380 |
+
};
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
async function deleteManagedClass(row){
|
| 384 |
+
if(!confirm(`למחוק את הכיתה ${row.name}? התלמידים שלה לא יוכלו להיכנס עוד.`)) return;
|
| 385 |
+
const state=sheet.querySelector(".mx-manage-state"); state.textContent=`מוחקים את ${row.name}…`;
|
| 386 |
+
try{
|
| 387 |
+
const response=await api(`/api/classes/${encodeURIComponent(row.id)}`,{method:"DELETE"});
|
| 388 |
+
if(!response.ok){const detail=(await response.json().catch(()=>({}))).detail;throw new Error(detail||"לא הצלחנו למחוק את הכיתה.");}
|
| 389 |
+
MANAGED_CLASSES=MANAGED_CLASSES.filter(item=>String(item.id)!==String(row.id));
|
| 390 |
+
MY_CLASSES=MY_CLASSES.filter(item=>String(item.id)!==String(row.id));
|
| 391 |
+
if(String(MY_CLASS?.id)===String(row.id)) MY_CLASS=MY_CLASSES.length===1?MY_CLASSES[0]:null;
|
| 392 |
+
SHOWN=new Set(shownUnits().map(unit=>unit.id)); draw(); state.textContent="הכיתה נמחקה."; drawManagedClasses(sheet.querySelector(".mx-management"));
|
| 393 |
+
}catch(error){state.textContent=error.message;}
|
| 394 |
}
|
| 395 |
|
| 396 |
/* A photographed problem (/api/math/photo, app/math_photo.py): hints first. The sheet shows the problem as it was
|
|
|
|
| 566 |
const response=await api("/api/me");
|
| 567 |
if(!response.ok){ state.className="error"; state.textContent=response.status===403?"החשבון אינו משויך לכיתת מתמטיקה.":"לא ניתן להשלים את הכניסה."; return; }
|
| 568 |
const me=await response.json();CFG.userId=me.user_id;CFG.answerScans=me.answer_scans; ME_EMAIL=me.email||""; MY_CLASSES=me.classes||[]; MY_CLASS=MY_CLASSES.length===1?MY_CLASSES[0]:null;
|
| 569 |
+
CFG.permissions=me.permissions||[]; CFG.roles=me.roles||[];
|
| 570 |
}
|
| 571 |
document.getElementById("boot").hidden=true;
|
| 572 |
show();
|
supabase/migrations/20261031213000_add_class_exam_board.sql
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
alter table public.classes
|
| 2 |
+
add column if not exists exams jsonb not null default '[]'::jsonb,
|
| 3 |
+
add column if not exists exam_title text not null default '',
|
| 4 |
+
add column if not exists exam_note text not null default '';
|
| 5 |
+
|
| 6 |
+
alter table public.classes drop constraint if exists classes_exams_are_array;
|
| 7 |
+
alter table public.classes
|
| 8 |
+
add constraint classes_exams_are_array check (jsonb_typeof(exams) = 'array');
|
tests/test_math_skills.py
CHANGED
|
@@ -35,6 +35,7 @@ def test_the_algebra_graph_is_whole():
|
|
| 35 |
assert problems(graph) == []
|
| 36 |
assert len(graph["skills"]) == 59
|
| 37 |
assert {unit["id"] for unit in graph["units"]} == {"f", "t", "n", "q", "p", "w", "g"}
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def test_a_broken_graph_is_named():
|
|
|
|
| 35 |
assert problems(graph) == []
|
| 36 |
assert len(graph["skills"]) == 59
|
| 37 |
assert {unit["id"] for unit in graph["units"]} == {"f", "t", "n", "q", "p", "w", "g"}
|
| 38 |
+
assert {unit["subject"] for unit in graph["units"]} == {"מתמטיקה"}
|
| 39 |
|
| 40 |
|
| 41 |
def test_a_broken_graph_is_named():
|
tests/test_phone_browser.py
CHANGED
|
@@ -103,7 +103,10 @@ def test_class_management_geometry_and_permissions(phone_server, manager):
|
|
| 103 |
import json
|
| 104 |
|
| 105 |
row = {"id": 7, "name": "כיתת תרגול", "lessons": ["f"],
|
| 106 |
-
"emails": ["student@example.com"], "teachers": ["teacher@example.com"]
|
|
|
|
|
|
|
|
|
|
| 107 |
writes = []
|
| 108 |
with playwright.sync_playwright() as p:
|
| 109 |
browser = p.chromium.launch()
|
|
@@ -138,12 +141,18 @@ def test_class_management_geometry_and_permissions(phone_server, manager):
|
|
| 138 |
|
| 139 |
page.route("**/api/classes/7", save)
|
| 140 |
page.goto(phone_server)
|
|
|
|
|
|
|
| 141 |
button = page.locator('[data-act="manage"]')
|
| 142 |
if not manager:
|
| 143 |
playwright.expect(page.locator('.mx-top')).to_be_visible()
|
| 144 |
playwright.expect(button).to_have_count(0)
|
| 145 |
return
|
|
|
|
|
|
|
| 146 |
button.click()
|
|
|
|
|
|
|
| 147 |
geometry = page.locator('input[name="unit"][value="g"]')
|
| 148 |
geometry.check()
|
| 149 |
page.get_by_role("button", name="שמירת השינויים").click()
|
|
|
|
| 103 |
import json
|
| 104 |
|
| 105 |
row = {"id": 7, "name": "כיתת תרגול", "lessons": ["f"],
|
| 106 |
+
"emails": ["student@example.com"], "teachers": ["teacher@example.com"],
|
| 107 |
+
"exam_title": "לוח מבחנים – כיתה ט׳11, תשפ״ז",
|
| 108 |
+
"exam_note": "ייתכנו שינויים.",
|
| 109 |
+
"exams": [{"date": "2026-11-16", "subject": "מתמטיקה"}]}
|
| 110 |
writes = []
|
| 111 |
with playwright.sync_playwright() as p:
|
| 112 |
browser = p.chromium.launch()
|
|
|
|
| 141 |
|
| 142 |
page.route("**/api/classes/7", save)
|
| 143 |
page.goto(phone_server)
|
| 144 |
+
playwright.expect(page.get_by_role("heading", name="המקצועות שלי")).to_be_visible()
|
| 145 |
+
page.locator('[data-subject="מתמטיקה"]').click()
|
| 146 |
button = page.locator('[data-act="manage"]')
|
| 147 |
if not manager:
|
| 148 |
playwright.expect(page.locator('.mx-top')).to_be_visible()
|
| 149 |
playwright.expect(button).to_have_count(0)
|
| 150 |
return
|
| 151 |
+
playwright.expect(page.locator(".mx-exam-countdown")).to_be_visible()
|
| 152 |
+
playwright.expect(page.locator(".mx-exam-countdown time")).to_contain_text("16 בנובמבר 2026")
|
| 153 |
button.click()
|
| 154 |
+
playwright.expect(page.locator(".mx-exam-board")).to_contain_text("מתמטיקה")
|
| 155 |
+
page.get_by_role("button", name="עריכה").click()
|
| 156 |
geometry = page.locator('input[name="unit"][value="g"]')
|
| 157 |
geometry.check()
|
| 158 |
page.get_by_role("button", name="שמירת השינויים").click()
|