+ ${classesCard()}${subjects.map(item=>subjectCard(item,today)).join("")}`;
}
function selectSubject(subject){
ACTIVE_SUBJECT=subject;
@@ -264,7 +267,7 @@ function skyStats(){
// the class's next exam in a subject of its units
function nextExam(){
const today=new Date(); today.setHours(0,0,0,0);
- const exams=(MY_CLASS?[MY_CLASS]:MY_CLASSES).flatMap(row=>{ const subjects=classSubjects(row); return (row.exams||[]).filter(e=>e?.date&&subjects.includes(e.subject)); })
+ const exams=examClasses().flatMap(row=>{ const subjects=classSubjects(row); return (row.exams||[]).filter(e=>e?.date&&subjects.includes(e.subject)); })
.filter(e=>examDate(e.date)>=today).sort((a,b)=>a.date.localeCompare(b.date));
return exams.length?{...exams[0],days:Math.round((examDate(exams[0].date)-today)/86400000)}:null;
}
@@ -360,7 +363,9 @@ function orbitRing(sts){
return ``;
}
function pathView(){
- const nxt=next(), cls=MY_CLASS?` · כיתת ${esc(MY_CLASS.name)}`:"", teach=can("students.view"), marks=asgMarks();
+ // a class's teacher gets the classes, not a learning path: no assignments, comments, practice or trail of their own
+ if(!PREVIEW&&teachClasses().length) return `${topBar()}${classesCard()}`;
+ const nxt=next(), cls=MY_CLASS?` · כיתת ${esc(MY_CLASS.name)}`:"", teach=can("students.view")&&!PREVIEW, marks=asgMarks();
const fix=new Set(myPatterns().recurring.map(x=>x.practise)); // skills that a recurring mistake sends back to
const units=mineUnits().map(u=>{
const ids=unitIds(u), act=ids.filter(live), lit=act.filter(id=>P.done[id]).length, n=ids.length, full=unitDone(u);
@@ -379,7 +384,7 @@ function pathView(){
${steps}
${teach?``:""}${quizStep(u)}`;
}).join("");
- return `${topBar()}${commentsCard()}${assignmentsCard()}${daily()}${reviewCard()}${patternsCard()}${examSoon()}${quizCard()}
- ${(MY_CLASS?[MY_CLASS]:MY_CLASSES).filter(row=>(row.exams||[]).length).map(row=>examBoard(row,true)).join("")}`;
+ ${examClasses().filter(row=>(row.exams||[]).length).map(row=>examBoard(row,true)).join("")}`;
}
function draw(){
@@ -462,7 +467,7 @@ async function loadAssignments(){
function asgView(){
if(!ASG?.assignments?.length) return null;
const now=new Date(), cutoff=Sky.jerusalemDay(now-14*864e5);
- const rows=ASG.assignments.map(a=>({a,s:Sky.assignmentState(a,P,ASG.sittings,now)}));
+ const rows=ASG.assignments.map(a=>({a,s:Sky.assignmentState(a,P,{sittings:ASG.sittings,submissions:ASG.submissions},now)}));
return {open:rows.filter(x=>!x.s.finished).sort((p,q)=>p.a.due_on.localeCompare(q.a.due_on)),
done:rows.filter(x=>x.s.finished&&x.a.due_on>=cutoff).sort((p,q)=>q.a.due_on.localeCompare(p.a.due_on))};
}
@@ -471,6 +476,7 @@ const asgUnit=id=>esc(G.units.find(u=>u.id===id)?.name||id);
function asgItemLabel(item){
if(item.kind==="skill") return BY[item.id]?fmt(BY[item.id].name):esc(item.id);
if(item.kind==="unit_quiz") return `בוחן מסכם: ${asgUnit(item.unit)}`;
+ if(item.kind==="worksheet") return `שיעורי בית: ${esc(wsTitle(item.id))}`;
return `מבחן תרגול: ${(item.units||[]).map(asgUnit).join(", ")}`;
}
// the skills of unfinished assignments, with the titles, for the marker on the path
@@ -479,24 +485,42 @@ function asgMarks(){
for(const {a} of asgView()?.open||[]) for(const item of a.items) if(item.kind==="skill") marks.set(item.id,[...(marks.get(item.id)||[]),a.title]);
return marks;
}
+// the time left to hand in, by Jerusalem's days; the last day counts the hours to midnight
+function asgLeft(due,now=new Date()){
+ const day=Sky.jerusalemDay(now), d=Math.round((Date.parse(due+"T00:00:00Z")-Date.parse(day+"T00:00:00Z"))/864e5);
+ if(d<0) return {late:true,text:d===-1?"באיחור של יום":`באיחור של ${-d} ימים`};
+ if(d===0){
+ const hour=+new Intl.DateTimeFormat("en-GB",{timeZone:"Asia/Jerusalem",hour:"numeric",hourCycle:"h23"}).format(now), left=24-hour;
+ return {late:false,soon:true,text:left<=1?"היום, נשארה פחות משעה":`היום, נשארו כ־${left} שעות`};
+ }
+ return {late:false,soon:d<=1,text:d===1?"מחר, נשאר יום אחד":d>60?"":`נשארו ${d} ימים`}; // far away: just the date
+}
function assignmentsCard(){
const v=asgView(); if(!v||(!v.open.length&&!v.done.length)) return "";
- const several=new Set(ASG.assignments.map(a=>a.class_id)).size>1;
+ const several=new Set(ASG.assignments.map(a=>a.class_id)).size>1, left=a=>asgLeft(a.due_on);
const open=v.open.map(({a,s})=>`
-
✓ ${esc(a.title)} עד ${asgDate(a.due_on)}${s.late?" · הוגשה באיחור":""}
`).join("")}
`:"";
+ `
✓ ${esc(a.title)} עד ${asgDate(a.due_on)}${s.late?" · הוגשה באיחור":""}${s.items.filter(x=>x.item.kind==="worksheet").map(x=>`
${asgItemLabel(x.item)}${asgSheetHtml(x)}
`).join("")}
`).join("")}`:"";
return `
משימות מהמורה
${open}${done}`;
}
+// a worksheet item on the card: handed in or checked (with the teacher's score and comment), and the button that opens it
+function asgSheetHtml(x){
+ const sub=x.submission, checked=sub?.checked_at;
+ const badge=checked?` נבדק${sub.score!=null?` · ${sub.score}`:""}`:x.done?' הוגש':"";
+ return `${badge}
+ ${checked&&sub.comment?`
המורה: ${esc(sub.comment)}
`:""}`;
+}
// opens the first item of the assignment that is not done
function asgGo(id){
const a=ASG?.assignments.find(x=>x.id===id); if(!a) return;
- const s=Sky.assignmentState(a,P,ASG.sittings), item=s.items[s.next]?.item; if(!item) return;
+ const s=Sky.assignmentState(a,P,{sittings:ASG.sittings,submissions:ASG.submissions}), item=s.items[s.next]?.item; if(!item) return;
if(item.kind==="skill") return BY[item.id]&&open(item.id);
+ if(item.kind==="worksheet") return wsOpen(item.id);
if(item.kind==="unit_quiz") return uqIntro(item.unit);
stopQuizClock(); QZ=null; return quizSetup(item.units);
}
@@ -593,7 +617,7 @@ function open(id){
if(openEnded&&st!=="off")window.MathlyAnswers?.mountQuestion(sheet.querySelector(".mx-answer"),id,s.question_version);
fillExplanations(sheet.querySelector("[data-skill-res]"),s.unit,r=>r.skill_id===id);
}
-function closeSheet(){ stopPhone(); dropResources(); dropQueue(); dropReview(); cancelAdvance(); REV=null; stopQuizClock(); QZ=null; stopUnitQuiz(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
+function closeSheet(){ stopPhone(); dropResources(); dropWorksheet(); dropQueue(); dropReview(); cancelAdvance(); REV=null; stopQuizClock(); QZ=null; stopUnitQuiz(); if(sheet)sheet.hidden=true; cur=null; back?.focus?.({preventScroll:true}); back=null; }
// counts down, then opens `target` (a skill id); stops if the student moved on in the meantime
function autoAdvance(id,target){
@@ -1049,6 +1073,12 @@ async function uqRefresh(i){
// waiting for the phone: look again soon
if(UQ?.uid===uid&&!UQ.res[i]?.attachment&&["waiting","confirmed","uploading","failed"].includes(UQ.res[i]?.scan?.state)){ clearTimeout(UQ.poll); UQ.poll=setTimeout(()=>sit.attempts.forEach((_,k)=>uqRefresh(k)),2000); }
}
+// a photo is on its way (this device's, or the phone's): handing in now would leave that answer out
+const uqUploading=()=>Object.values(UQ?.busy||{}).some(Boolean)||Object.values(UQ?.res||{}).some(r=>r&&!r.attachment&&r.scan?.state==="uploading");
+function uqPaintSubmit(){
+ const b=sheet.querySelector('[data-act="uq-submit"]'); if(!b) return;
+ const up=uqUploading(); b.disabled=up; b.textContent=up?"מעלים תמונה…":"הגשת הבוחן";
+}
function uqDrawAnswer(i){
const box=sheet.querySelector(`[data-uq-i="${i}"]`); if(!box) return;
const r=UQ.res[i], has=!!r?.attachment, busy=UQ.busy[i], st=r?.scan?.state, phone=["waiting","confirmed","uploading","failed"].includes(st)&&!has;
@@ -1061,6 +1091,7 @@ function uqDrawAnswer(i){
${window.MathlyBoard?``:""}${phoneBtn}`;
const done=Object.values(UQ.res).filter(x=>x?.attachment).length, n=uqState(UQ.uid).sitting.attempts.length;
const c=sheet.querySelector("[data-uq-count]"); if(c) c.textContent=`צולמו ${done} מתוך ${n} תשובות`;
+ uqPaintSubmit();
}
// a photo replaces an answer by a fresh attempt for that question (an attempt keeps the one photo it received)
async function uqFresh(i){
@@ -1103,6 +1134,7 @@ async function uqCancel(i){ // the computer withdraws the request
}
function uqSubmit(timeUp=false){
const uid=UQ.uid, sit=uqState(uid).sitting; if(!sit||sit.submitted) return;
+ if(!timeUp&&uqUploading()) return; // the button is disabled too; this covers a key press
const missing=sit.attempts.filter((_,i)=>!UQ.res[i]?.attachment).length;
if(!timeUp&&missing&&!UQ.sure){
UQ.sure=true;
@@ -1120,7 +1152,8 @@ function uqSubmit(timeUp=false){
async function uqSend(uid,entry){
if(entry.sid) return entry.sid;
if(!entry.ck){ entry.ck=crypto.randomUUID(); save(); }
- entry.sid=(await uqPost("/api/math/quiz-sittings",{unit_id:uid,attempts:entry.attempts,time_up:!!entry.timeUp,client_key:entry.ck})).id;
+ const d=await uqPost("/api/math/quiz-sittings",{unit_id:uid,attempts:entry.attempts,time_up:!!entry.timeUp,client_key:entry.ck});
+ entry.sid=d.id; if(d.code) entry.code=d.code;
save(); return entry.sid;
}
async function uqSitting(uid,sid){ return (await uqJson(`/api/math/quiz-sittings?unit_id=${encodeURIComponent(uid)}`)).sittings.find(x=>x.id===sid)||null; }
@@ -1133,21 +1166,23 @@ async function uqResult(uid,h,check=false){
sheet.innerHTML=`
${esc(u.name)}
בוחן מסכם: בודקים…
- ${CFG.aiDirect===false?"שולחים את התשובות לבדיקת המורה":"בודקים את התשובות, זה לוקח כחצי דקה לשאלה"}
+ ${CFG.aiDirect===false?"שולחים את התשובות לבדיקת המורה":check?"בודקים את התשובות, זה לוקח כחצי דקה לשאלה":"טוענים את התשובות"}
`;
sheet.hidden=false; sheet.querySelector(".mx-x").focus({preventScroll:true});
let sendError="";
commentSeen(c=>entry.attempts.includes(c.attempt_id));
try{ await uqSend(uid,entry); }catch(e){ sendError=e.message; }
if(UQ?.uid!==uid) return;
+ const codeTag=()=>entry.code?` · מספר הבוחן: ${esc(entry.code)}`:"";
+ if(entry.code) sheet.querySelector(".mx-src").innerHTML=(CFG.aiDirect===false?"שולחים את התשובות לבדיקת המורה":"בודקים את התשובות")+codeTag();
const results=[];
for(let i=0;ir?.ai_request?.code).find(Boolean)||""; // a hand-in from before, or another device
+ sheet.querySelector(".mx-src").innerHTML=(CFG.aiDirect===false?"שולחים את התשובות לבדיקת המורה":"")+codeTag();
// points: the server's, per question (a checked answer earns its share; a missing one 0; one not checked yet leaves the total open)
let sitting=null;
if(entry.sid){ try{ sitting=await uqSitting(uid,entry.sid); }catch(e){ sendError||=e.message; } }
@@ -1175,13 +1212,13 @@ async function uqResult(uid,h,check=false){
${a?.feedback?`
`;
const form=box.querySelector("form"), message=form.querySelector('[role="status"]');
const unitBoxes=subject=>[...form.querySelectorAll('input[name="unit"]')].filter(input=>input.dataset.subject===subject);
@@ -1560,20 +1614,17 @@ function openClassEditor(row){
if(input.name==="subject"||input.name==="unit") syncSubjects();
});
syncSubjects();
- // the teacher is picked from the students: the list follows the students box as it is edited
const lines=name=>form.elements[name].value.split(/[\n,;]+/).map(value=>value.trim().toLowerCase()).filter(Boolean);
const pickTeacher=form.elements.teacher;
- const syncTeacher=()=>{
- const chosen=pickTeacher.options.length?pickTeacher.value:(row?.teachers||[])[0]||"", students=[...new Set(lines("emails"))];
- pickTeacher.innerHTML=`${students.map(email=>``).join("")}`;
- };
- form.elements.emails.addEventListener("input",syncTeacher); syncTeacher();
form.querySelector('input[name="name"]').focus();
form.onsubmit=async event=>{
event.preventDefault();
const lessons=[...form.querySelectorAll('input[name="unit"]:checked')].map(input=>input.value);
if(!lessons.length){message.textContent="בחרו לפחות מקצוע אחד.";return;}
- const body={name:form.elements.name.value.trim(),emails:lines("emails"),teachers:pickTeacher.value?[pickTeacher.value]:[],lessons};
+ const teacher=pickTeacher.value.trim().toLowerCase(), students=lines("emails");
+ if(teacher&&!teacher.includes("@")){message.textContent="כתבו כתובת דוא״ל תקינה למורה.";return;}
+ if(students.includes(teacher)){message.textContent="מורה לא יכול להיות גם תלמיד בכיתה. הסירו אותו מרשימת התלמידים.";return;}
+ const body={name:form.elements.name.value.trim(),emails:students,teachers:teacher?[teacher]:[],lessons};
const buttons=form.querySelectorAll("button"); buttons.forEach(button=>button.disabled=true); message.textContent=creating?"פותחים את הכיתה…":"שומרים…";
try{
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)});
@@ -1608,8 +1659,19 @@ async function deleteManagedClass(row){
the mistakes that keep coming back (Sky.patterns), the summarizing quizzes' grades and the practice exams' results,
and above them the mistakes most common in the class. */
const teachClasses=()=>MY_CLASSES.filter(row=>row.teacher);
-const teachButton=()=>(teachClasses().length?'':"")
- +(CFG.aiReview?``:"");
+// "view as student": the teacher sees the home screen the way a student of the class does, until they switch back
+const previewBar=()=>PREVIEW?`
מצב תצוגה כתלמיד
`:"";
+function setPreview(on){ PREVIEW=on; closeSheet(); if(hasExams()) drawSubjectPicker(); else selectSubject("מתמטיקה"); window.scrollTo?.(0,0); }
+const teachButton=()=>CFG.aiReview?``:"";
+// the teacher's classes, at the top of the home screen: each opens its report (students, progress, assignments)
+function classesCard(){
+ const mine=PREVIEW?[]:teachClasses(); if(!mine.length) return "";
+ return `
הכיתות שלי
+
${mine.map(row=>``).join("")}
+ ${CFG.aiLeft!=null?`
${CFG.aiLeft?`נשארו לכם היום ${CFG.aiLeft===1?"בקשת AI אחת":`${CFG.aiLeft} בקשות AI`} מתוך 3.`:"השתמשתם היום בכל 3 בקשות ה-AI. אפשר שוב מחר."}
@@ -1660,7 +1722,7 @@ function reportOpenStudent(i){
card.open=true; card.scrollIntoView({block:"nearest"}); card.querySelector("summary").focus({preventScroll:true});
}
function closeSheetQuietly(){ const b=back; closeSheet(); back=b; }
-// the teacher's choices in the report (list or grid, which unit, the order) are kept on this device
+// the teacher's choices in the report (which tab, the order) are kept on this device
const repGet=(k,def)=>{ try{ return localStorage.getItem("mathly.report."+k)||def; }catch{ return def; } };
const repSet=(k,v)=>{ try{ localStorage.setItem("mathly.report."+k,v); }catch{} };
const REPORT_SORTS={attention:"דורשים תשומת לב קודם",name:"שם",progress:"התקדמות",activity:"פעילות אחרונה (הישנה קודם)"};
@@ -1672,6 +1734,11 @@ function cellState(p,id){
}
const CELL_TEXT={gold:"כוכב זהב",lit:"הושלמה",fading:"הושלמה, הכוכב דועך",trying:"מתרגל, עוד לא סיים",none:"לא התחיל"};
const HARD_SHARE=1/3; // a skill is a class-wide difficulty when this share of the students are on it without finishing
+// a unit's share of finished skills as a heat level: 0 not started, 1 under a third, 2 under two thirds, 3 under all, 4 all
+const heatLevel=(done,n,started)=>!n||(!done&&!started)?0:done>=n?4:done*3REPORT.open==null?new Set([new Date().toLocaleDateString("he-IL",{month:"long",timeZone:"Asia/Jerusalem"})])
+ :new Set(REPORT.open.split("|").filter(Boolean));
function drawReport(){
const {class:row,students}=REPORT.data, ids=new Set(row.lessons||[]);
const units=G.units.filter(u=>ids.has(u.id)&&unitIds(u).some(live));
@@ -1689,11 +1756,19 @@ function drawReport(){
const DEVICE_NOTE=' · נתוני תרגול מהמכשיר';
const grade=score=>score==null?"–":`${score}`;
const tone=score=>score==null?"":score>=80?" good":score>=55?" mid":" low";
- const card=({s,r},i)=>{
+ // a student's state at a glance: stuck on something, active this week, or quiet; and the last 14 days as small bars
+ const dayKey=d=>d.toLocaleDateString("en-CA",{timeZone:"Asia/Jerusalem"});
+ const status=r=>{ const week=Date.now()-7*864e5;
+ return r.perUnit.some(x=>x.stuck.length)?['low',"תקוע","יש לתלמיד לפחות מיומנות אחת שלא הושלמה ושיש בה שאלות שנענו לא נכון ועדיין לא נפתרו. זה לא ציון: אין סף, ומספיק שאלה אחת. נתוני המכשיר של התלמיד, והשרת לא בודק אותם."]:r.last&&r.last.getTime()>=week?['good',"פעיל"]:['mid',"לא פעיל"]; };
+ const spark=s=>{ const days=s.progress?.days||{}, now=Date.now();
+ const bars=Array.from({length:14},(_,k)=>days[dayKey(new Date(now-(13-k)*864e5))]||0), top=Math.max(5,...bars);
+ return `${bars.map(n=>``).join("")}`; };
+ const card=({s,r,att},i)=>{
if(!r) return `${esc(name(s.email))}${s.signed_in?"עוד לא התחיל לתרגל":"עוד לא נכנס לחשבון"}
${fmt(G.mistakes[x.m].name)} ${x.n} פעמים ב־${x.skills.length} מיומנויות, לאחרונה ${shortDate(x.last)}${BY[x.practise]?`לחזק: ${fmt(BY[x.practise].name)}`:""}
${r.recurring.map(x=>`${fmt(G.mistakes[x.m].name)}: ${x.n} פעמים ב־${x.skills.length} מיומנויות, לאחרונה ${shortDate(x.last)}${BY[x.practise]?`. לחזק: ${fmt(BY[x.practise].name)}`:""}`).join(" ")}
`:'
אין טעויות שחוזרות לאחרונה.
'}
${r.perUnit.some(x=>x.stuck.length)?`
מיומנויות עם שאלות שעוד לא נפתרו: ${r.perUnit.flatMap(x=>x.stuck).slice(0,8).map(sk=>fmt(sk.name)).join(" · ")}
'}`;
- // the grid: a row per student; a column per skill of one unit, or per unit in the overview
+ // the map: a row per student; a column per unit, or (one unit chosen) per skill. The colour says how far the student is,
+ // the red mark says they are stuck on something there. It is the device's data, as the rest of the progress
+ const mapUnit=units.find(u=>u.id===REPORT.unit);
const started=all.filter(x=>x.r).length;
- const gridUnit=units.find(u=>u.id===REPORT.unit);
- let grid;
- if(!students.length) grid='
';
+ else if(!mapUnit){
+ // two levels: the units are grouped by the month the syllabus teaches them in; a month's column opens into its units
+ const monthOf=u=>(u.when||"ללא מועד").split(/[–-]/)[0].trim();
+ const groups=[]; units.forEach((u,k)=>{ const key=monthOf(u); let g=groups.find(x=>x.key===key); if(!g) groups.push(g={key,ks:[]}); g.ks.push(k); });
+ const openG=heatOpen(), isOpen=g=>openG.has(g.key), rows=groups.some(isOpen)?2:1;
+ const stuckIn=ks=>order.filter(({r})=>r&&ks.some(k=>r.perUnit[k].stuck.length)).length;
+ const hard=ks=>stuckIn(ks)>0&&stuckIn(ks)*3>=Math.max(started,1);
+ const cell=(s,r,ks,title)=>{
+ if(!r) return `
`:view==="tasks"||view==="subs"?(REPORT.sub?wsSubHtml(name):view==="subs"?subsTabHtml(order,name):REPORT.form?asgFormHtml(units):asgTasksHtml(order,name)):list);
+ if((view==="tasks"||view==="subs")&&REPORT.sub) wsSubImages();
}
/* ---------- the class's assignments, in the report (app/assignments.py) ----------
@@ -1768,7 +1865,7 @@ function drawReport(){
and gives the teacher the form to make, change or delete one. */
const ASG_ERR={400:"אחד הפריטים אינו שייך ליחידות של הכיתה, או שהתאריך אינו תקין.",403:"רק מורה הכיתה יכול לתת לה משימות.",404:"הכיתה או המשימה לא נמצאו.",
503:"המשימות אינן זמינות כרגע. נסו שוב מאוחר יותר."};
-const asgStates=(a,order)=>order.map(({s})=>({s,st:Sky.assignmentState(a,s.progress,s.quiz_sittings)}));
+const asgStates=(a,order)=>order.map(({s})=>({s,st:Sky.assignmentState(a,s.progress,wsServer(s))}));
function asgTasksHtml(order,name){
const list=REPORT.data.assignments;
if(list===null) return '
המשימות אינן זמינות כרגע באתר.
';
@@ -1778,75 +1875,498 @@ function asgTasksHtml(order,name){
return `
${esc(a.title)}${past?"המועד עבר · ":""}עד ${asgDate(a.due_on)}
${a.note?`
${esc(a.note)}
`:""}
${finished} מתוך ${rows.length} תלמידים סיימו
-
${a.items.map(i=>`
${asgItemLabel(i)}
`).join("")}
- ${rows.length?`מי עשה מה
+
${a.items.map(i=>`
${asgItemLabel(i)}
`).join("")}
${wsSummaryHtml(a,order)}
+ ${rows.length?`st.items.some(x=>x.submission&&!x.submission.checked_at))?" open":""}>מי עשה מה
תלמיד
${a.items.map(i=>`
${asgItemLabel(i)}
`).join("")}
- ${rows.map(({s,st})=>`
${esc(name(s.email))}
${st.items.map(x=>
+ ${rows.map(({s,st})=>`
${esc(name(s.email))}
${st.items.map(x=>wsCellHtml(x,a,s)||
`
${x.done?(x.late?"✓ באיחור":"✓"):x.known?"–":"?"}
`).join("")}
`).join("")}
`:""}
`;
}).join("");
- return `
- ${cards||'
עוד לא נתתם משימות לכיתה. משימה היא מיומנויות לסיים, בוחן מסכם להגיש או מבחן תרגול, עד תאריך.
'}`;
+ return `
+ ${cards||`
${asgFull()?"עוד לא נתתם משימות לכיתה. ":"עוד לא נתתם שיעורי בית לכיתה. "}${asgFull()?"משימה היא מיומנויות לסיים, בוחן מסכם להגיש, מבחן תרגול או שיעורי בית, עד תאריך.":"שיעורי בית הם PDF או תמונות שהתלמידים פותרים ומגישים עד תאריך יעד."}
`}`;
}
+// a teacher gives worksheets only; the full picker (skills, unit quizzes, practice exams) is for developers
+const asgFull=()=>can("management.access");
function asgFormHtml(units){
const old=REPORT.data.assignments.find(x=>x.id===REPORT.form.id);
const a=old||{title:"",note:"",due_on:new Date(Date.now()+7*864e5).toLocaleDateString("en-CA",{timeZone:"Asia/Jerusalem"}),items:[]};
const skills=new Set(a.items.filter(i=>i.kind==="skill").map(i=>i.id)), quizzes=new Set(a.items.filter(i=>i.kind==="unit_quiz").map(i=>i.unit));
const exam=new Set(a.items.find(i=>i.kind==="exam")?.units||[]);
const box=(name,value,on,label)=>``;
- return ``;
}
async function asgSave(form){
- const id=form.dataset.asgForm, status=form.querySelector("[role=status]"), button=form.querySelector("[type=submit]"), classId=REPORT.id;
+ const id=form.dataset.asgForm, status=form.querySelector(":scope>.mx-row>[role=status]"), button=form.querySelector("[type=submit]"), classId=REPORT.id; // the save's own line, not the worksheet upload's
const picked=name=>[...form.querySelectorAll(`[name=${name}]:checked`)].map(c=>c.value);
const items=[...picked("skill").map(v=>({kind:"skill",id:v})),...picked("quiz").map(v=>({kind:"unit_quiz",unit:v}))];
if(picked("exam").length) items.push({kind:"exam",units:picked("exam")});
+ if(!asgFull()) items.push(...(REPORT.data.assignments.find(x=>x.id===id)?.items||[]).filter(i=>i.kind!=="worksheet")); // kept as they were
+ const before=(REPORT.data.assignments.find(x=>x.id===id)?.items||[]).filter(i=>i.kind==="worksheet").map(i=>i.id);
+ // without the worksheet list (not available), the assignment keeps the worksheets it had
+ for(const w of REPORT.form?.sheets||before.map(wid=>({id:wid}))) items.push({kind:"worksheet",id:w.id});
const body={title:form.elements.title.value,note:form.elements.note.value,due_on:form.elements.due_on.value,items};
if(!body.title.trim()){ status.textContent="כתבו כותרת למשימה."; form.elements.title.focus(); return; }
if(!body.due_on){ status.textContent="בחרו תאריך להגשה."; return; }
- if(!items.length){ status.textContent="בחרו לפחות פריט אחד: מיומנות, בוחן מסכם או מבחן תרגול."; return; }
+ if(!items.length){ status.textContent=asgFull()?"בחרו לפחות פריט אחד: מיומנות, בוחן מסכם, מבחן תרגול או שיעורי בית.":"העלו שיעורי בית אחד לפחות."; return; }
button.disabled=true; status.textContent="שומרים…";
try{
const r=await api(`/api/classes/${encodeURIComponent(classId)}/assignments${id?`/${encodeURIComponent(id)}`:""}`,
{method:id?"PUT":"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
const d=await r.json().catch(()=>({}));
- if(!r.ok) throw Error(ASG_ERR[r.status]||"השמירה נכשלה. נסו שוב.");
+ if(!r.ok) throw Error(d.detail==="teachers_give_worksheets_only"?"מורה נותן שיעורי בית בלבד.":ASG_ERR[r.status]||"השמירה נכשלה. נסו שוב.");
if(cur!=="report"||REPORT?.id!==classId) return;
const list=REPORT.data.assignments||[];
REPORT.data.assignments=id?list.map(x=>x.id===id?d:x):[d,...list];
+ wsAfterSave(before,d.items.filter(i=>i.kind==="worksheet").map(i=>i.id));
REPORT.form=null; drawReport();
sheet.querySelector(".mx-report").insertAdjacentHTML("afterbegin",`
${id?"המשימה עודכנה.":"המשימה נשמרה. התלמידים רואים אותה בבית."}
`);
}catch(e){ if(cur==="report"){ button.disabled=false; status.textContent=e.message; } }
}
async function asgDelete(id){
const a=REPORT.data.assignments.find(x=>x.id===id); if(!a) return;
- const started=REPORT.data.students.filter(s=>Sky.assignmentState(a,s.progress,s.quiz_sittings).doneCount>0).length;
- if(!confirm(`למחוק את המשימה "${a.title}"? התלמידים לא יראו אותה יותר.${started?` ${started} תלמידים כבר השלימו לפחות פריט אחד ממנה.`:""}`)) return;
+ const started=REPORT.data.students.filter(s=>Sky.assignmentState(a,s.progress,wsServer(s)).doneCount>0).length;
+ const sheets=a.items.filter(i=>i.kind==="worksheet").map(i=>i.id), handed=REPORT.data.students.filter(s=>(s.worksheet_submissions||[]).some(x=>sheets.includes(x.worksheet_id))).length;
+ if(!confirm(`למחוק את המשימה "${a.title}"? התלמידים לא ירא�� אותה יותר.${started?` ${started} תלמידים כבר השלימו לפחות פריט אחד ממנה.`:""}${handed?` ${handed} תלמידים הגישו דפי עבודה, וההגשות יימחקו.`:""}`)) return;
const classId=REPORT.id;
try{
const r=await api(`/api/classes/${encodeURIComponent(classId)}/assignments/${encodeURIComponent(id)}`,{method:"DELETE"});
if(!r.ok) throw Error(ASG_ERR[r.status]||"המחיקה נכשלה. נסו שוב.");
if(cur!=="report"||REPORT?.id!==classId) return;
- REPORT.data.assignments=REPORT.data.assignments.filter(x=>x.id!==id); drawReport();
+ REPORT.data.assignments=REPORT.data.assignments.filter(x=>x.id!==id); wsAfterSave(sheets,[]); drawReport();
}catch(e){ if(cur==="report") sheet.querySelector(".mx-manage-state").textContent=e.message; }
}
-// the report's own controls: the view, the order and the unit of the grid
+/* ---------- worksheets on assignments (app/worksheets.py) ----------
+ The teacher uploads a PDF or photos from the assignment form; it becomes a {kind:"worksheet", id} item when the
+ assignment is saved. Students open its pages (rendered on the server) from the card, and hand in photos of their
+ solution, which they can replace until the teacher checks. The teacher checks each submission from the report's
+ "משימות" tab: a score and a comment, both optional. */
+const WS_ERR={too_many_pages:"אפשר עד 10 עמודים.",pdf_too_large:"הקובץ גדול מדי (עד 10MB).",file_too_large:"הקובץ גדול מדי.",
+ image_too_large:"תמונה גדולה מדי (עד 5MB).",not_a_pdf:"הקובץ אינו PDF תקין.",unreadable_pdf:"לא הצלחנו לקרוא את ה־PDF. אם הוא מוגן בסיסמה, שמרו עותק בלי סיסמה.",
+ pdf_too_slow:"ה־PDF כבד מדי לעיבוד. נסו לשמור אותו מחדש, או להעלות צילומים.",unsupported_image:"אפשר להעלות PDF או תמונות (JPEG, PNG, WebP).",
+ empty_image:"הקובץ ריק.",one_pdf_or_photos:"העלו PDF אחד, או תמונות, לא את שניהם יחד.",no_files:"בחרו קובץ.",
+ worksheets_unavailable:"שיעורי הבית אינם זמינים כרגע. נסו שוב מאוחר יותר.",already_checked:"המורה כבר בדק/ה את ההגשה, ולכן אי אפשר להחליף אותה.",
+ worksheet_in_use:"שיעורי הבית כבר נשמרו במשימה.",
+ open_ended_locked:"בדיקת AI נעולה כרגע.",grading_unavailable:"בדיקה אוטומטית אינה זמינה כרגע באתר.",
+ teacher_ai_limit:"הגעתם למכסה של 3 בקשות AI ביום. אפשר לבדוק ידנית, או לנסות שוב מחר."};
+const wsError=(r,fallback)=>WS_ERR[typeof r.data?.detail==="string"?r.data.detail:""]||(r.status===0?"אין חיבור לשרת. בדקו את החיבור ונסו שוב.":r.status===403?"אין לכם הרשאה לפעולה הזו.":r.status===404?"שיעורי הבית לא נמצאו.":fallback);
+// a form sent with upload progress (fetch has none)
+function sendForm(url,method,body,onProgress){
+ return new Promise(resolve=>{
+ const x=new XMLHttpRequest(); x.open(method,url);
+ if(CFG.token) x.setRequestHeader("Authorization",`Bearer ${CFG.token}`);
+ x.upload.onprogress=e=>{ if(e.lengthComputable) onProgress?.(e.loaded/e.total); };
+ x.onload=()=>{ let data={}; try{ data=JSON.parse(x.responseText); }catch{} resolve({ok:x.status>=200&&x.status<300,status:x.status,data}); };
+ x.onerror=x.ontimeout=()=>resolve({ok:false,status:0,data:{}});
+ x.send(body);
+ });
+}
+// a photo made smaller on the phone before it is sent; the file itself when that fails and it is an image we take
+async function wsPrepare(file){
+ let blob=null;
+ try{ blob=await window.MathlyPhoto.prepare(file); }catch{}
+ if(!blob&&["image/jpeg","image/png","image/webp"].includes(file.type)) blob=file;
+ return blob&&blob.size<=5000000?blob:null;
+}
+const wsAll=()=>[...(REPORT?.data?.worksheets||[]),...(REPORT?.form?.sheets||[]),...(ASG?.worksheets||[])];
+const wsInfo=id=>wsAll().find(w=>w.id===id)||null;
+const wsTitle=id=>wsInfo(id)?.title||"שיעורי בית";
+const wsWhen=t=>new Date(t).toLocaleString("he-IL",{day:"numeric",month:"numeric",hour:"2-digit",minute:"2-digit",timeZone:"Asia/Jerusalem"});
+const wsLate=(sub,a)=>!!sub&&Sky.jerusalemDay(sub.submitted_at)>a.due_on;
+const wsServer=s=>({sittings:s.quiz_sittings,submissions:s.worksheet_submissions});
+const wsHanded=id=>(REPORT?.data?.students||[]).filter(s=>(s.worksheet_submissions||[]).some(x=>x.worksheet_id===id)).length;
+async function wsBlob(url){ try{ const r=await api(url); return r.ok?URL.createObjectURL(await r.blob()):null; }catch{ return null; } }
+
+// ---- the teacher: in the assignment form
+function wsListHtml(){
+ const list=REPORT.form?.sheets||[];
+ return list.map(w=>`
+ ${group(`שיעורי בית`,hwList,"עוד לא הוגשו שיעורי בית.")}
+ ${group(`בחנים מסכמים`,quizRows,sit==null?"הבחנים אינם זמינים כרגע.":"עוד לא הוגש בוחן מסכם.")}
+ ${group(`מבחני תרגול${noteDevice}`,examRows,"עוד לא נעשה מבחן תרגול.")}
+
${waitingAll?`${waitingAll} הגשות ממתינות לבדיקה. `:"אין הגשות שממתינות לבדיקה. "}שיעורי בית אפשר לבדוק ידנית או עם AI בפתיחת ההגשה. בחנים מסכמים נבדקים בפתיחה, וה־AI בודק אותם דרך תור ה־AI.${CFG.aiReview?' ':""}
+ ${cards||'
עוד אין תלמידים בכיתה.
'}`;
+}
+
+// ---- the student: the worksheet and the hand-in
+let WS=null; // {id, urls (pages), files (chosen photos, prepared), previews, msg, busy, progress}
+function dropWorksheet(){
+ if(WS){
+ clearTimeout(WS.timer); const sid=WS.phone?.res?.scan?.id;
+ if(sid) uqPost(`/api/math/answer-scans/${sid}/decline`).catch(()=>{}); // a request for the phone that nobody waits for
+ window.MathlyBoard?.closeFull();
+ for(const u of [...WS.urls,...WS.previews]) if(u) URL.revokeObjectURL(u); WS=null;
+ }
+ wsSubClose();
+}
+const wsAssignment=id=>ASG?.assignments.find(a=>a.items.some(i=>i.kind==="worksheet"&&i.id===id));
+const wsMine=id=>(ASG?.submissions||[]).find(x=>x.worksheet_id===id)||null;
+function wsOpen(id){
+ if(!cur) back=document.activeElement;
+ closeSheetQuietly(); cur="worksheet";
+ WS={id,urls:[],files:[],previews:[],msg:"",busy:false,progress:0,fresh:true};
+ drawWorksheet(); wsPages(WS);
+}
+function wsHandHtml(w,a){
+ const sub=wsMine(w.id);
+ if(ASG.submissions===null) return '
אי אפשר לבדוק כרגע אם הגשתם. נסו שוב מאוחר יותר.
';
+ if(sub?.checked_at) return `
✔ המורה בדק/ה${sub.score!=null?` · ציון ${sub.score}`:""}
+ ${WS.busy?``:""}`;
+}
+// on a computer the phone is asked to photograph a page: its code and progress show in the box (like an exercise's answer)
+function wsPhoneHtml(){
+ const touch=matchMedia("(max-width: 700px)").matches||navigator.maxTouchPoints>0;
+ if(touch||!window.MathlyAnswers||!CFG.answerScans||!CFG.userId) return "";
+ return window.MathlyAnswers.phoneLive(WS.phone?.res?.scan,"data-ws-phone","data-ws-cancel","צילום בטלפון",WS.busy||WS.files.length>=10||!!WS.phone?.starting);
+}
+function drawWorksheet(){
+ const top=sheet.querySelector(".mx-sheet")?.scrollTop||0;
+ const w=ASG?.worksheets?.find(x=>x.id===WS.id), a=wsAssignment(WS.id);
+ const body=!w||!a?'
שיעורי הבית אינם זמינים כרגע.
':`
+
${w.source==="pdf"?'':'אפשר לפתוח כל עמוד בגודל מלא ולשמור אותו.'}
${rest.map(card).join("")}`:""}`;
- open.forEach(r=>aqImage(r.id));
+ // by student, then by subject (the same quiz: its label without the " · שאלה N"); newest first within each
+ const subjectOf=r=>String(r.label||"").replace(/\s*·\s*שאלה\s*\d+\s*$/,"").trim()||(r.kind==="photo"?"תרגילים מצולמים":"ללא נושא");
+ const grouped=rows=>{
+ const students=new Map(), last=new Map();
+ // a request before the codes existed has none: the requests of one hand-in were made within moments of each other
+ const clusterKey=r=>{
+ const id=`${r.email}|${subjectOf(r)}`, t=Date.parse(r.created_at), prev=last.get(id);
+ if(!prev||Math.abs(prev.t-t)>120000) last.set(id,{t,n:(prev?.n||0)+1}); else prev.t=t;
+ return `${subjectOf(r)}@${last.get(id).n}`;
+ };
+ rows.forEach(r=>{ const s=students.get(r.email)||students.set(r.email,new Map()).get(r.email), k=r.code?`${subjectOf(r)}#${r.code}`:/·\s*שאלה\s*\d+\s*$/.test(r.label||"")?clusterKey(r):subjectOf(r); (s.get(k)||s.set(k,[]).get(k)).push(r); }); // one hand-in (its code) is one group: two sittings of the same quiz stay apart
+ return [...students].map(([email,subjects])=>`
`; // one request: a plain card (its card shows the hand-in number)
+ // a collection (a quiz): one request that opens into a card per question, each handled on its own
+ const key=email+"|"+key0, waiting=list.filter(aqWaiting).length;
+ return `${fmtLoose(name)}
+ ${list.length}${aqCode(list)}${waiting?`${waiting} ממתינות`:"טופלו"}${list.map(card).join("")}`;
+ }).join("")}`).join("");
+ };
+ box.innerHTML=grouped(done?rest:open);
+ box.querySelectorAll("[data-aq-group]").forEach(d=>d.addEventListener("toggle",()=>{ const k=d.dataset.aqGroup; d.open?AQ.openGroups.add(k):AQ.openGroups.delete(k); }));
+ (done?[]:open).forEach(r=>aqImage(r.id));
}
// students who asked the teacher to look again at a checked answer
function drawRequests(){
@@ -2052,8 +2597,9 @@ async function aqPost(id,what,body){
try{
const r=await api(`/api/ai-queue/${encodeURIComponent(id)}/${what}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body||{})});
const d=await r.json().catch(()=>({}));
+ if(d.detail==="teacher_ai_limit") CFG.aiLeft=0;
if(!r.ok) throw Error(AQ_ERR[d.detail]||"הפעולה נכשלה. נסו שוב.");
- row=d;
+ row=d; if(what==="send"&&d.state==="done") aiUsed();
}catch(e){ err=e.message; }
mine.busy.delete(id); if(AQ!==mine) return;
if(row){ const i=mine.rows.findIndex(x=>x.id===id); if(i>=0) mine.rows[i]=row; }
@@ -2064,7 +2610,7 @@ function aqDecline(id){
const note=prompt("הערה לתלמיד (לא חובה), למשל: צלמו שוב, התמונה לא ברורה");
if(note!==null) return aqPost(id,"decline",{note});
}
-async function aqAll(){ for(const r of (AQ?.rows||[]).filter(x=>x.state==="pending")){ if(!AQ) return; await aqSend(r.id); } }
+async function aqAll(){ for(const r of (AQ?.rows||[]).filter(x=>x.state==="pending")){ if(!AQ||CFG.aiLeft===0) return; await aqSend(r.id); } }
/* A photographed problem (/api/math/photo, app/math_photo.py): hints first. The sheet shows the problem as it was
read (a wrong reading is the student's cue to photograph again), then one hint; each further hint, each step and
@@ -2082,7 +2628,7 @@ async function photo(file){
const form=new FormData(); form.append("photo",blob,"problem.jpg");
const res=await api("/api/math/photo",{method:"POST",body:form}), data=await res.json().catch(()=>({}));
if(!res.ok) throw new Error(typeof data.detail==="string"?data.detail:`השרת ענה ${res.status}`);
- if(data.queued){ mine.req=data.request; mine.left=data.left; } else mine.data=data;
+ if(data.queued){ mine.req=data.request; mine.left=data.left; } else { mine.data=data; if(data.readable) aiUsed(); }
}catch(e){ mine.error=e.message; }
mine.busy=false;
if(PH===mine&&cur==="photo") drawPhoto(); // a newer photo, or a closed sheet, keeps its own
@@ -2476,6 +3022,7 @@ async function boot(){
if(!response.ok){ state.className="error"; state.textContent=response.status===403?"החשבון אינו משויך לכיתת מתמטיקה.":"לא ניתן להשלים את הכניסה."; return; }
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;
CFG.permissions=me.permissions||[]; CFG.roles=me.roles||[]; CFG.aiDirect=me.ai_direct!==false; CFG.aiReview=!!me.ai_review;
+ CFG.aiLeft=typeof me.ai_left==="number"?me.ai_left:null; // a teacher's AI requests left today (app/teacher_ai.py)
}
document.getElementById("boot").hidden=true;
show();