3v324v23 commited on
Commit
8417250
·
1 Parent(s): 63ddff9

feat: add class exam navigation

Browse files

Additive migration stores exam schedules on classes. Unit subjects drive subject selection and route learners into the matching path.

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"{s['id']}: unknown unit {s['unit']}" for s in skills.values() if s["unit"] not in units]
 
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
- do $$ begin
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)); draw();
 
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-shtop"><h3 id="mxTitle">ניהול כיתות</h3><button class="mx-x" data-act="close" aria-label="סגירה">✕</button></div>
198
- <div class="mx-management"><p role="status">טוענים את הכיתות…</p></div></div>`;
 
 
 
 
 
 
199
  sheet.hidden=false; sheet.querySelector(".mx-x").focus();
200
- const box=sheet.querySelector(".mx-management");
 
 
 
 
 
 
 
201
  try{
202
  const response=await api("/api/classes");
203
  if(!response.ok) throw new Error("לא הצלחנו לטעון את הכיתות. נסו שוב.");
204
  const {classes}=await response.json();
205
- if(!box.isConnected) return;
206
- if(!classes.length){box.innerHTML='<p>אין כיתות לניהול.</p>';return;}
207
- box.innerHTML=`<label>כיתה<select class="mx-class-picker">${classes.map(c=>`<option value="${esc(c.id)}">${esc(c.name)}</option>`).join("")}</select></label><div class="mx-class-editor"></div>`;
208
- const select=box.querySelector("select"), editor=box.querySelector(".mx-class-editor");
209
- if(MY_CLASS&&classes.some(c=>c.id===MY_CLASS.id)) select.value=String(MY_CLASS.id);
210
- function edit(){
211
- const row=classes.find(c=>String(c.id)===select.value);
212
- editor.innerHTML=`<form><fieldset><legend>יחידות הלימוד בכיתה</legend>${G.units.map(u=>`<label><input type="checkbox" name="unit" value="${esc(u.id)}"${row.lessons.includes(u.id)?" checked":""}> ${esc(u.name)}</label>`).join("")}</fieldset>
213
- <button class="mx-btn solid" type="submit">שמירת השינויים</button><p role="status" aria-live="polite"></p></form>`;
214
- const form=editor.querySelector("form"), message=form.querySelector('[role="status"]'), submit=form.querySelector('button');
215
- form.onsubmit=async event=>{
216
- event.preventDefault();
217
- const lessons=[...form.querySelectorAll('input:checked')].map(input=>input.value);
218
- if(!lessons.length){message.textContent="בחרו לפחות יחידת לימוד אחת.";return;}
219
- submit.disabled=true; select.disabled=true; message.textContent="שומרים…";
220
- try{
221
- const saved=await api(`/api/classes/${encodeURIComponent(row.id)}`,{method:"PUT",headers:{"Content-Type":"application/json"},
222
- body:JSON.stringify({name:row.name,emails:row.emails||[],teachers:row.teachers||[],lessons})});
223
- if(!saved.ok) throw new Error("לא הצלחנו לשמור את השינויים. נסו שוב.");
224
- row.lessons=lessons;
225
- MY_CLASSES.forEach(c=>{if(c.id===row.id)c.lessons=lessons;});
226
- if(MY_CLASS?.id===row.id) MY_CLASS.lessons=lessons;
227
- SHOWN=new Set(shownUnits().map(u=>u.id)); draw();
228
- message.textContent="השינויים נשמרו. אפשר לחזור למסלול.";
229
- }catch(error){message.textContent=error.message;}
230
- finally{submit.disabled=false;select.disabled=false;}
231
- };
232
- }
233
- select.onchange=edit; edit();
234
- }catch(error){box.textContent=error.message;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()