diff --git "a/static/math.js" "b/static/math.js" --- "a/static/math.js" +++ "b/static/math.js" @@ -16,7 +16,7 @@ const cssReady=new Promise(done=>{ css.onload=css.onerror=done; }); // until t document.head.append(css); const CFG=window.MATHLY||{}, AUTH_URL=CFG.supabaseUrl||""; -let ME_EMAIL="", MY_CLASSES=[], MY_CLASS=null, client=null; +let ME_EMAIL="", MY_CLASSES=[], MY_CLASS=null, client=null, PREVIEW=false; // PREVIEW: a teacher looking at the app as a student const can=permission=>(CFG.permissions||[]).includes("*")||(CFG.permissions||[]).includes(permission); const api=(url,options={})=>fetch(url,{...options,headers:{...(options.headers||{}),...(CFG.token?{Authorization:`Bearer ${CFG.token}`}:{})}}); @@ -158,8 +158,9 @@ async function show(){ root.addEventListener("submit",e=>{ if(e.target.matches?.("[data-review-form]")){ e.preventDefault(); reviewSave(e.target); } else if(e.target.matches?.("[data-asg-form]")){ e.preventDefault(); asgSave(e.target); } + else if(e.target.matches?.("[data-ws-check]")){ e.preventDefault(); wsCheck(e.target); } }); - root.addEventListener("change",e=>{ if(e.target.matches?.("[data-report-sort],[data-report-unit]")) reportControl(e.target); }); + root.addEventListener("change",e=>{ if(e.target.matches?.("[data-report-sort],[data-report-unit]")) reportControl(e.target); else if(e.target.matches?.("[data-ws-file]")) wsAddPhotos(e.target); }); document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&helpOpen()) return helpClose(); if(e.key==="Escape"&&cur) QZ?.items?quizQuit():closeSheet(); }); // an exam asks before it is left } root.hidden=false; @@ -189,10 +190,12 @@ function hide(){ cancelAdvance(); if(root) root.hidden=true; document.documentEl // subjects with a path of their own: always offered, even before the class has an exam in them const PATH_SUBJECTS=["מתמטיקה","מדעים"]; -const hasExams=()=>MY_CLASSES.some(row=>(row.exams||[]).some(exam=>exam?.subject&&exam?.date)); +// the classes whose exams a screen shows: a student's own. A teacher sees no exam schedule at all (it is edited under ניהול) +const examClasses=()=>!PREVIEW&&(can("students.view")||teachClasses().length)?[]:MY_CLASS?[MY_CLASS]:MY_CLASSES; +const hasExams=()=>examClasses().some(row=>(row.exams||[]).some(exam=>exam?.subject&&exam?.date)); function subjectOptions(){ const bySubject=new Map(PATH_SUBJECTS.map(name=>[name,{name,dates:[],classes:new Set()}])); - MY_CLASSES.forEach(row=>(row.exams||[]).forEach(exam=>{ + examClasses().forEach(row=>(row.exams||[]).forEach(exam=>{ if(!exam?.subject||!exam?.date)return; const item=bySubject.get(exam.subject)||{name:exam.subject,dates:[],classes:new Set()}; item.dates.push(exam.date);item.classes.add(row.name);bySubject.set(exam.subject,item); @@ -221,8 +224,8 @@ function subjectCard(item,today){ function drawSubjectPicker(){ ACTIVE_SUBJECT=""; nav.hidden=true; const manage=can("management.access"), subjects=subjectOptions(), today=new Date();today.setHours(0,0,0,0); - path.innerHTML=`

המקצועות שלי

בחרו מקצוע כדי לפתוח את המסלול שלו

${window.MathlyGuide?.button()||""}${teachButton()}${manage?'':""}${window.MathlyAnswers?.pushButton?.()||""}${AUTH_URL?'':""}
-
${subjects.map(item=>subjectCard(item,today)).join("")}
`; + path.innerHTML=`${previewBar()}

המקצועות שלי

בחרו מקצוע כדי לפתוח את המסלול שלו

${window.MathlyGuide?.button()||""}${teachButton()}${manage?'':""}${window.MathlyAnswers?.pushButton?.()||""}${AUTH_URL?'':""}
+ ${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()}

המסלול שלי

אלגברה וגאומטריה כיתה ט׳ · 5 יח״ל${cls}

+ return `${previewBar()}${topBar()}${classesCard()}${commentsCard()}${assignmentsCard()}${daily()}${reviewCard()}${patternsCard()}${examSoon()}${quizCard()}

המסלול שלי

אלגברה וגאומטריה כיתה ט׳ · 5 יח״ל${cls}

${Sky.owl(40)}התחלה
${units}
@@ -398,7 +403,7 @@ function collectionView(){

שביטים חולפים לפעמים על המסך בזמן תרגול. מי שלוחץ עליהם בזמן תופס אותם.

${Sky.COMETS.map(c=>{ const have=P.comets.includes(c.id); return ``; }).join("")}
- ${(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)}${s.overdue?"באיחור · ":""}עד ${asgDate(a.due_on)}
+
${esc(a.title)}${s.overdue?"באיחור · ":""}${left(a).text?`⏳ ${left(a).text} · `:""}עד ${asgDate(a.due_on)}
${several?`${esc(a.class_name)}`:""}${a.note?`

${esc(a.note)}

`:""}

${s.doneCount} מתוך ${s.total} הושלמו

- +
`).join(""); const done=v.done.length?`
הושלמו (${v.done.length})
`:""; + `
  • ✓ ${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=`
    `; 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?`

    ${r.teacher_review?"בדיקה אוטומטית: ":""}${fmtLoose(a.feedback)}

    `:""}${a?.next_step?`

    הצעד הבא: ${fmtLoose(a.next_step)}

    `:""} ${a?.solution?`
    הפתרון המלא

    ${fmtLoose(a.solution)}

    `:""} ${r?.attachment?window.MathlyAnswers?.reviewBox?.(entry.attempts[i],r)||"":""} - ${r?.attachment&&!a&&r.grading_available&&!["pending","sending","failed"].includes(r.ai_request?.state)?``:""}`; + ${r?.attachment&&!a&&r.grading_available&&!["pending","sending","failed"].includes(r.ai_request?.state)?``:""}`; }); const earned=sitting?sitting.questions.reduce((sum,x)=>sum+(x.earned||0),0):0, open=sitting?sitting.open:n; entry.score=sitting?.grade??null; save(); draw(); const total=!sitting?"ההגשה לא נשמרה בשרת":open?`${earned} נקודות עד עכשיו · ${open===1?"שאלה אחת לא נבדקה":`${open} שאלות לא נבדקו`}`:`${earned}/100`; sheet.querySelector("#mxTitle").textContent=`בוחן מסכם: ${total}`; - sheet.querySelector(".mx-src").textContent=`הוגש ${new Date(entry.at).toLocaleString("he-IL",{dateStyle:"short",timeStyle:"short"})}${entry.timeUp?" · הוגש כשהזמן נגמר":""}`; + sheet.querySelector(".mx-src").innerHTML=esc(`הוגש ${new Date(entry.at).toLocaleString("he-IL",{dateStyle:"short",timeStyle:"short"})}${entry.timeUp?" · הוגש כשהזמן נגמר":""}`)+codeTag(); const weak=u.quiz.questions.map((q,i)=>i).filter(i=>{ const a=results[i]?.grading?.result; return !results[i]?.attachment||(a&&a.score!=null&&a.score<70); }); sheet.querySelector(".mx-uq-report").innerHTML=`
      ${rows.join("")}
    ${sendError&&!sitting?`

    ${esc(sendError)}

    `:""} @@ -1210,8 +1247,22 @@ function onClick(e){ if(t.dataset.asgGo) return asgGo(t.dataset.asgGo); if(t.dataset.asgNew!=null){ REPORT.form={id:null}; drawReport(); return sheet.querySelector(".mx-asg-form input[name=title]")?.focus(); } if(t.dataset.asgEdit){ REPORT.form={id:t.dataset.asgEdit}; drawReport(); return sheet.querySelector(".mx-asg-form input[name=title]")?.focus(); } - if(t.dataset.asgCancel!=null){ REPORT.form=null; return drawReport(); } + if(t.dataset.asgCancel!=null){ wsCancel(); REPORT.form=null; return drawReport(); } if(t.dataset.asgDel) return asgDelete(t.dataset.asgDel); + if(t.dataset.wsOpen) return wsOpen(t.dataset.wsOpen); + if(t.dataset.wsUpload!=null) return wsUpload(t); + if(t.dataset.wsRemove) return wsRemove(t.dataset.wsRemove); + if(t.dataset.wsSub) return wsSubOpen(t.dataset.wsSub); + if(t.dataset.wsSubBack!=null){ wsSubClose(); drawReport(); return sheet.querySelector(`[data-report-tab="${REPORT.tab}"]`)?.focus(); } + if(t.dataset.wsAi!=null) return wsAiCheck(t); + if(t.dataset.wsPick!=null) return sheet.querySelector("[data-ws-file]")?.click(); + if(t.dataset.wsSend!=null) return wsSend(); + if(t.dataset.wsBoard!=null) return wsBoard(); + if(t.dataset.wsPhone!=null) return wsPhone(); + if(t.dataset.wsCancel!=null) return wsPhoneCancel(); + if(t.dataset.wsUp) return wsMove(+t.dataset.wsUp,false); + if(t.dataset.wsDrop) return wsMove(+t.dataset.wsDrop,true); + if(t.dataset.wsDownload!=null) return wsDownload(t); if(t.dataset.uqResult){ const [uid,h,check]=t.dataset.uqResult.split(":"); return uqResult(uid,+h,check==="check"); } if(t.dataset.uqPhoto!=null){ UQ.pick=+t.dataset.uqPhoto; return sheet.querySelector("[data-uq-file]").click(); } if(t.dataset.uqPhone!=null) return uqPhone(+t.dataset.uqPhone); @@ -1225,19 +1276,21 @@ function onClick(e){ if(t.dataset.classAction) return manageClassAction(t.dataset.classAction,t.dataset.classId); if(t.dataset.reportClass!=null) return classReport(t.dataset.reportClass); if(t.dataset.reportStudent!=null) return reportOpenStudent(t.dataset.reportStudent); - if(t.dataset.reportTab) return reportControl(t); + if(t.dataset.reportTab||t.dataset.heatUnit||t.dataset.heatGroup||t.dataset.heatAll) return reportControl(t); if(t.dataset.aqSend) return aqSend(t.dataset.aqSend); if(t.dataset.aqDecline) return aqDecline(t.dataset.aqDecline); switch(t.dataset.act){ case "help-close": return helpClose(); case "manage": return manageClasses(); - case "class-report": return classReport(); + case "class-report": return classReport(t.dataset.classId); case "ai-queue": return aiQueue(); case "aq-all": return aqAll(); case "photo-cancel": return photoCancel(); case "manage-refresh": return loadManagedClasses(true); case "manage-lab": return showManageMode("lab"); case "manage-practice": return closeSheet(); + case "preview-on": return setPreview(true); + case "preview-off": return setPreview(false); case "subjects": return drawSubjectPicker(); case "streak": return showStreak(); case "review": return startReview(); @@ -1369,6 +1422,7 @@ function showManageMode(mode){ Each source loads and fails on its own, so a slow Supabase does not hold up the rest. */ const MON_RANGES={day:["יום","24 השעות האחרונות","15 דקות"],week:["שבוע","7 הימים האחרונים","שעה"],month:["חודש","30 הימים האחרונים","יום"]}; const MON_ACTIONS={visit:["👋","נכנס/ה לאפליקציה"],photo:["📷","צילם/ה תרגיל לעזרה"],answer_upload:["📝","שלח/ה תשובה פתוחה"],answer_graded:["✔️","תשובה נבדקה"],class_saved:["🏫","שמר/ה כיתה"],class_deleted:["🗑️","מחק/ה כיתה"],resource_added:["🖼️","הוסיף/ה תמונה למשאבי שיעור"], + worksheet_added:["📄","העלה/תה שיעורי בית"],worksheet_submitted:["📬","הגיש/ה שיעורי בית"],worksheet_checked:["✅","בדק/ה שיעורי בית"],worksheet_ai_checked:["🤖","בדק/ה שיעורי בית עם AI"], ai_request:["🙋","ביקש/ה בדיקת AI מהמורה"],ai_sent:["🤖","שלח/ה בקשה של תלמיד ל-AI"],ai_failed:["⚠️","שליחה ל-AI נכשלה"],ai_declined:["✋","דחה/תה בקשת AI"]}; const VERDICTS={correct:"נכונות",partial:"נכונות חלקית",incorrect:"שגויות",unreadable:"לא קריאות",needs_review:"דורשות בדיקה",grading:"בבדיקה",failed:"הבדיקה נכשלה"}; let MON={}, MON_RANGE=(()=>{try{return localStorage.getItem("mathly.monRange")}catch{return null}})(); @@ -1537,7 +1591,7 @@ function openClassEditor(row){

    בחרו מקצוע כדי לראות את יחידות הלימוד שלו.

    ${subjects.map(subject=>`
    ${esc(subject)}
    ${subjectUnits(subject).map(unit=>{const skills=unitSkills(unit.id);return ``}).join("")}
    `).join("")} - +
    `; 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. אפשר שוב מחר."}

    `:""}
    `; +} +const aiUsed=()=>{ if(CFG.aiLeft!=null) CFG.aiLeft=Math.max(0,CFG.aiLeft-1); }; let REPORT=null; const shortDate=at=>{ const d=new Date(at); return isNaN(d)?"":d.toLocaleDateString("he-IL",{day:"numeric",month:"numeric"}); }; const pct=(a,b)=>b?Math.round(100*a/b):0; @@ -1639,7 +1701,7 @@ function reportOf(student,units){ async function classReport(id){ const mine=teachClasses(), classId=id??REPORT?.id??mine[0]?.id; if(classId==null) return; if(!cur) back=document.activeElement; - closeSheetQuietly(); cur="report"; REPORT={id:classId,data:null,form:null,tab:repGet("tab","list"),sort:repGet("sort","attention"),unit:repGet("unit","overview")}; + closeSheetQuietly(); cur="report"; REPORT={id:classId,data:null,form:null,tab:repGet("tab","list"),sort:repGet("sort","attention"),unit:"overview",open:repGet("mapMonths",null)}; const tabs=mine.length>1?`
    ${mine.map(row=>``).join("")}
    `:""; sheet.innerHTML=`
    `; }; @@ -1730,36 +1806,57 @@ function drawReport(){ ${top.length?`

    קשיים נפוצים בכיתה

    `:""}

    תלמידים

    ${students.length?order.map(x=>card(x,x.i)).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(!gridUnit){ - grid=`${units.map(u=>``).join("")}${ - order.map(({s,r})=>`${units.map((u,k)=>{ const x=r?.perUnit[k]; - return x?``:''; }).join("")}`).join("")}
    תלמיד${esc(u.name)}
    ${esc(name(s.email))}${x.done}/${x.n}–
    `; + let map; + if(!students.length) map='

    אין עדיין תלמידים בכיתה.

    '; + 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 ``; + const done=ks.reduce((t,k)=>t+r.perUnit[k].done,0), n=ks.reduce((t,k)=>t+r.perUnit[k].n,0), stuck=ks.reduce((t,k)=>t+r.perUnit[k].stuck.length,0); + const label=`${title}: ${done} מתוך ${n} מיומנויות${stuck?`, תקוע ב־${stuck}`:""}`; + return `${done?`${pct(done,n)}%`:""}`; }; + const head1=groups.map(g=>isOpen(g) + ?`` + :``).join(""); + const head2=rows===2?`${groups.filter(isOpen).flatMap(g=>g.ks).map(k=> + ``).join("")}`:""; + map=`${head1}${head2}${ + order.map(({s,r})=>`${groups.map(g=>isOpen(g)?g.ks.map(k=>cell(s,r,[k],units[k].name)).join(""):cell(s,r,g.ks,g.key)).join("")}`).join("")}
    תלמיד
    ${esc(name(s.email))}
    +

    לחיצה על חודש פותחת אותו ליחידות שלו, ולחיצה על שם יחידה פותחת אותה לפי מיומנויות. נקודה אדומה: התלמיד תקוע בלפחות מיומנות אחת.

    `; }else{ - const skills=unitIds(gridUnit).filter(live); + const skills=unitIds(mapUnit).filter(live); const count=skills.map(id=>{ const st=all.map(x=>cellState(x.s.progress,id)); const trying=st.filter(t=>t==="trying").length; return {id,done:st.filter(t=>["gold","lit","fading"].includes(t)).length,trying,hard:trying>0&&trying>=started*HARD_SHARE}; }); - grid=`${count.map(c=> - ``).join("")}${ + map=`
    תלמיד${c.hard?"⚠ ":""}${fmt(BY[c.id].short)}
    ${count.map(c=> + ``).join("")}${ order.map(({s})=>`${skills.map(id=>{ const st=cellState(s.progress,id); return ``; }).join("")}`).join("")}${count.map(c=>``).join("")}${count.map(c=>``).join("")}
    תלמיד${c.hard?"⚠ ":""}${fmt(BY[c.id].short||BY[c.id].name)}
    ${esc(name(s.email))}
    סיימו${c.done}/${started}
    מתקשים${c.trying||""}

    ⚠ מיומנות שלפחות שליש מהתלמידים מתרגלים ועוד לא סיימו.

    `; } - const view=["grid","tasks"].includes(REPORT.tab)?REPORT.tab:"list"; + const mapLegend=mapUnit?"":``; + const view=["map","tasks","subs"].includes(REPORT.tab)?REPORT.tab:"list"; const bar=`
    - -
    + + + +
    - ${view==="grid"?``:""}`; - sheet.querySelector(".mx-report").innerHTML=bar+(view==="grid"?`
    ${grid}
    ` - :view==="tasks"?(REPORT.form?asgFormHtml(units):asgTasksHtml(order,name)):list); + ${view==="map"&&!mapUnit?``:""} + ${view==="map"?``:""}`; + sheet.querySelector(".mx-report").innerHTML=bar+(view==="map"?`${mapLegend}
    ${map}
    `: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} תלמידים סיימו

    - - ${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=>``).join("")} - ${rows.map(({s,st})=>`${st.items.map(x=> + ${rows.map(({s,st})=>`${st.items.map(x=>wsCellHtml(x,a,s)|| ``).join("")}`).join("")}
    תלמיד${asgItemLabel(i)}
    ${esc(name(s.email))}
    ${esc(name(s.email))}${x.done?(x.late?"✓ באיחור":"✓"):x.known?"–":"?"}
    `:""}
    `; }).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 `

    ${old?"עריכת משימה":"משימה חדשה"}

    + return `

    ${asgFull()?(old?"עריכת משימה":"משימה חדשה"):(old?"עריכת שיעורי בית":"שיעורי בית חדשים")}

    - -
    מיומנויות לסיים${units.map(u=>{ const ids=unitIds(u).filter(live); + + ${asgFull()?`
    מיומנויות לסיים${units.map(u=>{ const ids=unitIds(u).filter(live); return `skills.has(id))?" open":""}>${esc(u.name)}
    ${ids.map(id=>box("skill",id,skills.has(id),fmt(BY[id].short||BY[id].name))).join("")}
    `; }).join("")}
    בוחן מסכם להגשה
    ${units.filter(u=>u.quiz).map(u=>box("quiz",u.id,quizzes.has(u.id),esc(u.name))).join("")||"ליחידות של הכיתה אין בוחן מסכם."}
    מבחן תרגול על היחידות
    ${units.map(u=>box("exam",u.id,exam.has(u.id),esc(u.name))).join("")}
    - מי שיסמן כמה יחידות יבקש מבחן תרגול שכולל את כולן.
    + מי שיסמן כמה יחידות יבקש מבחן תרגול שכולל את כולן.
    `:""} + ${wsFieldHtml(old)||(asgFull()?"":'

    שיעורי הבית אינם זמינים כרגע, ולכן אי אפשר לשמור אותם.

    ')}
    `; } 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=>`
  • 📄 ${esc(w.title)} ${w.pages} עמ׳ · ${w.source==="pdf"?"PDF":"תמונות"} +
  • `).join(""); +} +function wsFieldHtml(old){ + if(!Array.isArray(REPORT.data.worksheets)) return ""; // the worksheet tables are not there: no worksheets + if(!REPORT.form.sheets) REPORT.form.sheets=(old?.items||[]).filter(i=>i.kind==="worksheet").map(i=>wsInfo(i.id)||{id:i.id,title:"שיעורי בית",pages:"?",source:""}); + return `
    שיעורי בית + +
    + +
    +
    + PDF אחד של עד 10 עמודים, או עד 10 תמונות. אפשר להוסיף כמה שיעורי בית שרוצים. התלמידים פותחים אותם, מצלמים את הפתרון ומגישים עד תאריך היעד, ואתם בודקים בלשונית שיעורי בית או הגשות.
    `; +} +async function wsUpload(button){ + const form=button.closest("form"), status=form.querySelector("[data-ws-status]"), bar=form.querySelector("[data-ws-progress]"); + const title=form.elements.ws_title.value.trim(), files=[...form.elements.ws_files.files], classId=REPORT.id, mine=REPORT.form; + const pdfs=files.filter(f=>f.type==="application/pdf"||/\.pdf$/i.test(f.name)); + if(!title){ status.textContent="כתבו שם לשיעורי הבית."; return form.elements.ws_title.focus(); } + if(!files.length){ status.textContent="בחרו PDF או תמונות."; return form.elements.ws_files.focus(); } + if(pdfs.length&&files.length>1){ status.textContent=WS_ERR.one_pdf_or_photos; return; } + if(files.length>10){ status.textContent="אפשר עד 10 תמונות."; return; } + button.disabled=true; status.textContent="מכינים…"; + const body=new FormData(); body.append("title",title); + if(pdfs.length) body.append("files",files[0],files[0].name||"worksheet.pdf"); + else for(const [i,f] of files.entries()){ + const blob=await wsPrepare(f); + if(!blob){ button.disabled=false; status.textContent=`לא הצלחנו להכין את התמונה ${i+1}. נסו תמונה אחרת.`; return; } + body.append("files",blob,`page${i+1}.jpg`); + } + status.textContent="מעלים…"; bar.hidden=false; bar.value=0; + const r=await sendForm(`/api/classes/${encodeURIComponent(classId)}/worksheets`,"POST",body,p=>bar.value=p); + if(cur!=="report"||REPORT?.form!==mine) return; + button.disabled=false; bar.hidden=true; + if(!r.ok){ status.textContent=wsError(r,"ההעלאה נכשלה. נסו שוב."); return; } + mine.sheets=[...(mine.sheets||[]),r.data]; + form.querySelector("[data-ws-list]").innerHTML=wsListHtml(); + form.elements.ws_title.value=""; form.elements.ws_files.value=""; + status.textContent=`"${r.data.title}" הועלו (${r.data.pages} עמ׳). התלמידים יראו אותם כשתשמרו.`; +} +function wsRemove(id){ + const mine=REPORT.form, old=REPORT.data.assignments.find(x=>x.id===mine.id), saved=(old?.items||[]).some(i=>i.kind==="worksheet"&&i.id===id); + const n=saved?wsHanded(id):0; + if(n&&!confirm(`להסיר את "${wsTitle(id)}"? ${n} תלמידים כבר הגישו אותו, וההגשות שלהם יימחקו כשתשמרו את המשימה.`)) return; + mine.sheets=(mine.sheets||[]).filter(w=>w.id!==id); + const list=sheet.querySelector("[data-ws-list]"); if(list) list.innerHTML=wsListHtml(); + if(!saved) api(`/api/classes/${encodeURIComponent(REPORT.id)}/worksheets/${encodeURIComponent(id)}`,{method:"DELETE"}).catch(()=>{}); // not saved yet: gone now +} +// the form closed without saving: worksheets uploaded in it, and not in the assignment, go now (else the hourly cleanup) +function wsCancel(){ + const old=REPORT.data.assignments.find(x=>x.id===REPORT.form?.id), kept=new Set((old?.items||[]).filter(i=>i.kind==="worksheet").map(i=>i.id)); + for(const w of REPORT.form?.sheets||[]) if(!kept.has(w.id)) + api(`/api/classes/${encodeURIComponent(REPORT.id)}/worksheets/${encodeURIComponent(w.id)}`,{method:"DELETE"}).catch(()=>{}); +} +// after a save: the report knows the assignment's worksheets, and forgets those it lost +function wsAfterSave(before,after){ + const gone=new Set(before.filter(id=>!after.includes(id))); + const known=(REPORT.data.worksheets||[]).filter(w=>!gone.has(w.id)); + for(const w of REPORT.form?.sheets||[]) if(after.includes(w.id)&&!known.some(k=>k.id===w.id)) known.push(w); + if(Array.isArray(REPORT.data.worksheets)) REPORT.data.worksheets=known; + for(const s of REPORT.data.students) if(s.worksheet_submissions) s.worksheet_submissions=s.worksheet_submissions.filter(x=>!gone.has(x.worksheet_id)); +} + +// ---- the teacher: in the report's assignments tab +function wsSummaryHtml(a,order){ + return a.items.filter(i=>i.kind==="worksheet").map(i=>{ + if(order.some(({s})=>s.worksheet_submissions==null)) return `

    📄 ${esc(wsTitle(i.id))}: ההגשות אינן זמינות כרגע.

    `; + const subs=order.map(({s})=>s.worksheet_submissions.find(x=>x.worksheet_id===i.id)).filter(Boolean); + const late=subs.filter(x=>wsLate(x,a)).length, waiting=subs.filter(x=>!x.checked_at).length; + return `

    📄 ${esc(wsTitle(i.id))}: הגישו ${subs.length} מתוך ${order.length}${late?` (${late} באיחור)`:""} · ${order.length-subs.length} לא הגישו · ${waiting} ממתינים לבדיקה

    `; + }).join(""); +} +function wsCellHtml(x,a,s){ + const sub=x.submission; + if(!sub) return null; + const label=sub.checked_at?(sub.score!=null?`✔ ${sub.score}`:"✔ נבדק"):"לבדיקה"; + return ``; +} +function wsSubOpen(key){ + const [wid,email]=key.split("|"); + REPORT.sub={wid,email,urls:[]}; drawReport(); + sheet.querySelector(".mx-ws-review [data-ws-sub-back]")?.focus(); +} +function wsSubClose(){ for(const u of REPORT?.sub?.urls||[]) if(u) URL.revokeObjectURL(u); if(REPORT) REPORT.sub=null; } +function wsSubHtml(name){ + const {wid,email}=REPORT.sub, s=REPORT.data.students.find(x=>x.email===email); + const sub=(s?.worksheet_submissions||[]).find(x=>x.worksheet_id===wid); + const a=REPORT.data.assignments.find(x=>x.items.some(i=>i.kind==="worksheet"&&i.id===wid)); + if(!s||!sub) return `

    ההגשה לא נמצאה.

    `; + return `
    +

    ${esc(wsTitle(wid))} · ${esc(name(email))}

    +

    הוגש ${esc(wsWhen(sub.submitted_at))}${a&&wsLate(sub,a)?' · באיחור':""} · ${sub.pages} עמ׳${sub.checked_at?` · נבדק ${esc(wsWhen(sub.checked_at))}`:""}

    +
    ${Array.from({length:sub.pages},(_,i)=>`עמוד ${i+1} של ההגשה`).join("")}
    +
    + ${CFG.aiLeft!=null?`נשארו לכם היום ${CFG.aiLeft} מתוך 3 בקשות AI. `:""}ה־AI מציע ציון והערה, ואתם מאשרים או מתקנים.
    +
    + + +
    `; +} +async function wsSubImages(){ + const mine=REPORT?.sub; if(!mine) return; + const s=REPORT.data.students.find(x=>x.email===mine.email), sub=(s?.worksheet_submissions||[]).find(x=>x.worksheet_id===mine.wid); + if(!sub) return; + for(let i=0;i=0&&score<=100)){ status.textContent="הציון הוא מספר שלם בין 0 ל־100, או ריק."; return form.elements.score.focus(); } + button.disabled=true; status.textContent="שומרים…"; + try{ + const r=await api(`/api/classes/${encodeURIComponent(REPORT.id)}/worksheets/${encodeURIComponent(mine.wid)}/submissions/${encodeURIComponent(form.dataset.wsCheck)}`, + {method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({score,comment})}); + const d=await r.json().catch(()=>({})); + if(!r.ok) throw Error(wsError({status:r.status,data:d},"השמירה נכשלה. נסו שוב.")); + if(REPORT?.sub!==mine) return; + const s=REPORT.data.students.find(x=>x.email===mine.email); + s.worksheet_submissions=s.worksheet_submissions.map(x=>x.id===d.id?d:x); + wsSubClose(); drawReport(); + sheet.querySelector(".mx-report").insertAdjacentHTML("afterbegin",'

    הבדיקה נשמרה. התלמיד/ה רואה אותה בכרטיס המשימה.

    '); + }catch(e){ if(REPORT?.sub===mine){ button.disabled=false; status.textContent=e.message; } } +} + +// the AI's suggestion for this submission fills the form; the teacher decides what is saved +async function wsAiCheck(button){ + const mine=REPORT?.sub, status=sheet.querySelector("[data-ws-ai-status]"), form=sheet.querySelector("[data-ws-check]"); + if(!mine||!form) return; + button.disabled=true; status.textContent="ה־AI בודק… זה לוקח כחצי דקה."; + try{ + const r=await api(`/api/classes/${encodeURIComponent(REPORT.id)}/worksheets/${encodeURIComponent(mine.wid)}/submissions/${encodeURIComponent(form.dataset.wsCheck)}/ai-check`,{method:"POST"}); + const d=await r.json().catch(()=>({})); + if(REPORT?.sub!==mine) return; + if(!r.ok){ + if(d.detail==="teacher_ai_limit") CFG.aiLeft=0; + throw Error(String(d.detail||"").startsWith("ai_check_failed")?"הבדיקה של ה־AI נכשלה. אפשר לנסות שוב או לבדוק ידנית.":wsError({status:r.status,data:d},"הבדיקה של ה־AI נכשלה. אפשר לבדוק ידנית.")); + } + if(typeof d.left==="number") CFG.aiLeft=d.left; + if(d.score!=null) form.elements.score.value=d.score; + form.elements.comment.value=d.comment||""; + status.textContent=d.readable?"הצעת ה־AI הוכנסה לטופס. בדקו, תקנו אם צריך, ולחצו על הכפתור כדי לשמור.":"ה־AI לא הצליח לקרוא את הפתרון (לא נספר מהמכסה). ההערה שלו בטופס, ואפשר לבדוק ידנית."; + form.elements.comment.focus({preventScroll:true}); + }catch(e){ if(REPORT?.sub===mine) status.textContent=e.message; } + finally{ button.disabled=false; } +} + +// ---- the report's "הגשות" tab: everything the students handed in, by student and by kind +function subsTabHtml(order,name){ + const students=REPORT.data.students, noteDevice=' · מהמכשיר'; + const hwRows=s=>(s.worksheet_submissions||[]).slice().sort((p,q)=>String(q.submitted_at).localeCompare(String(p.submitted_at))); + const cards=order.map(({s,i})=>{ + const exams=(s.progress?.quizzes||[]).slice().reverse(); + const sit=s.quiz_sittings, latest=new Map((sit||[]).map(x=>[x.unit_id,x.id])); + const quizzes=(sit||[]).slice().reverse(), homework=hwRows(s); + const waiting=homework.filter(x=>!x.checked_at).length+quizzes.filter(x=>x.grade==null).length; + const total=exams.length+quizzes.length+homework.length; + const group=(title,rows,empty)=>`
    ${title}
    ${rows.length?`
      ${rows.join("")}
    `:`

    ${empty}

    `}
    `; + const examRows=exams.map(q=>`
  • ${(q.units||[]).map(asgUnit).join(", ")||"מבחן תרגול"} ${esc(shortDate(q.at))} · ${q.right}/${q.n} נכונות (${pct(q.right,q.n)}%)
  • `); + const quizRows=sit==null?[]:quizzes.map(x=>`
  • ${asgUnit(x.unit_id)} ${esc(shortDate(x.submitted_at))} · ${x.grade==null?'ממתין לבדיקה':`ציון ${x.grade}/100`} + ${latest.get(x.unit_id)===x.id?``:""}
  • `); + const hwList=homework.map(x=>{ const a=REPORT.data.assignments?.find(y=>y.items.some(it=>it.kind==="worksheet"&&it.id===x.worksheet_id)), late=a&&wsLate(x,a); + return `
  • ${esc(wsTitle(x.worksheet_id))} ${esc(wsWhen(x.submitted_at))}${late?" · באיחור":""} · ${x.checked_at?(x.score!=null?`נבדק, ציון ${x.score}`:"נבדק"):'ממתין לבדיקה'} +
  • `; }); + return `
    ${esc(name(s.email))} + ${total} הגשות${waiting?`${waiting} ממתינות לבדיקה`:""} +
    + ${group(`שיעורי בית`,hwList,"עוד לא הוגשו שיעורי בית.")} + ${group(`בחנים מסכמים`,quizRows,sit==null?"הבחנים אינם זמינים כרגע.":"עוד לא הוגש בוחן מסכם.")} + ${group(`מבחני תרגול${noteDevice}`,examRows,"עוד לא נעשה מבחן תרגול.")} +
    `; + }).join(""); + const waitingAll=order.reduce((n,{s})=>n+hwRows(s).filter(x=>!x.checked_at).length+(s.quiz_sittings||[]).filter(x=>x.grade==null).length,0); + return `

    ${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}`:""}

    + ${sub.comment?`

    ${esc(sub.comment)}

    `:""}

    הוגש ${esc(wsWhen(sub.submitted_at))}${wsLate(sub,a)?" · באיחור":""} · ${sub.pages} עמ׳

    `; + const chosen=WS.files.length?`
      ${WS.files.map((_,i)=>`
    1. תמונה ${i+1} + עמוד ${i+1}${i?``:""} +
    2. `).join("")}
    `:""; + return `${sub?`

    ✓ הוגש ${esc(wsWhen(sub.submitted_at))}${wsLate(sub,a)?" · באיחור":""} · ${sub.pages} עמ׳. אפשר להחליף את ההגשה עד שהמורה יבדוק.

    ` + :`

    פתרתם? צלמו את הדפים של הפתרון (עד 10) והגישו למורה.${Sky.jerusalemDay(new Date())>a.due_on?" תאריך היעד עבר, ואפשר עדיין להגיש. ההגשה תסומן באיחור.":""}

    `} + ${chosen} + +
    + ${window.MathlyBoard?``:""} + ${wsPhoneHtml()} + ${WS.files.length?``:""}
    + ${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"?'':'אפשר לפתוח כל עמוד בגודל מלא ולשמור אותו.'}
    +
    ${Array.from({length:w.pages},(_,i)=>`עמוד ${i+1} מתוך ${w.pages}`).join("")}
    +

    ההגשה שלי

    ${wsHandHtml(w,a)} +

    ${esc(WS.msg)}

    `; + sheet.innerHTML=`
    + `; + sheet.hidden=false; + if(WS.fresh){ WS.fresh=false; sheet.querySelector(".mx-x").focus({preventScroll:true}); } + else sheet.querySelector(".mx-sheet").scrollTop=top; +} +// a page written on the board joins the hand-in like a photo +function wsBoard(){ + const mine=WS; if(!mine||mine.busy||!window.MathlyBoard) return; + window.MathlyBoard.openFull({host:document.body,key:`ws:${mine.id}`,title:"פתרון בלוח",sendLabel:"הוספה להגשה", + submit:async(_,picture)=>{ + if(WS!==mine) return {error:"החלון נסגר"}; + if(mine.files.length>=10) return {error:"אפשר עד 10 עמודים."}; + const blob=await wsPrepare(picture); if(WS!==mine) return {error:"החלון נסגר"}; + if(!blob) return {error:"לא ניתן להכין את התמונה"}; + mine.files.push(blob); mine.previews.push(URL.createObjectURL(blob)); mine.msg="העמוד מהלוח נוסף להגשה."; + drawWorksheet(); return {close:true}; + }}); +} +async function wsPhone(){ + const mine=WS; if(!mine||mine.busy||mine.phone) return; + mine.phone={starting:true}; mine.msg=""; drawWorksheet(); + try{ + const r=await uqPost("/api/math/worksheet-attempts",{worksheet_id:mine.id,new:true}); + const res=await uqPost(`${UQ_BASE}/${r.attempt.id}/scans`,{renew:true}); + if(WS!==mine) return; + mine.phone={aid:r.attempt.id,res,state:res.scan?.state}; + }catch(e){ if(WS!==mine) return; mine.phone=null; mine.msg=e.message; } + drawWorksheet(); wsPhoneWatch(mine); +} +async function wsPhoneCancel(){ + const mine=WS, sid=mine?.phone?.res?.scan?.id; if(!sid) return; + clearTimeout(mine.timer); mine.phone=null; + try{ await uqPost(`/api/math/answer-scans/${sid}/decline`); }catch{} + if(WS===mine){ mine.msg="הבקשה בוטלה."; drawWorksheet(); } +} +// looks every 2 seconds until the phone has sent the photo (it joins the hand-in) or the request ends +function wsPhoneWatch(mine){ + clearTimeout(mine.timer); + const p=mine.phone; if(!p?.aid) return; + mine.timer=setTimeout(async()=>{ + if(WS!==mine||mine.phone!==p) return; + try{ + const r=await uqJson(`${UQ_BASE}/${p.aid}`); + if(WS!==mine||mine.phone!==p) return; + p.res=r; + if(r.attachment){ + const b=await api(`${UQ_BASE}/${p.aid}/image`,{cache:"no-store"}); + if(WS!==mine||mine.phone!==p) return; + if(!b.ok) throw Error("לא הצלחנו לקבל את התמונה מהטלפון. נסו שוב."); + const blob=await b.blob(); if(WS!==mine||mine.phone!==p) return; + mine.phone=null; + if(mine.files.length>=10) mine.msg="אפשר עד 10 עמודים."; + else{ mine.files.push(blob); mine.previews.push(URL.createObjectURL(blob)); mine.msg="התמונה מהטלפון נוספה להגשה."; } + return drawWorksheet(); + } + const st=r.scan?.state; + if(["waiting","confirmed","uploading","failed"].includes(st)){ + if(st!==p.state){ p.state=st; drawWorksheet(); } // redraw only when the phone moved on + return wsPhoneWatch(mine); + } + mine.phone=null; mine.msg=st==="declined"?"הבקשה נדחתה בטלפון.":st==="expired"?"בקשת הצילום פגה. אפשר לבקש שוב.":"הבקשה הסתיימה."; + }catch(e){ if(WS!==mine||mine.phone!==p) return; mine.phone=null; mine.msg=e.message; } + drawWorksheet(); + },2000); +} +async function wsPages(mine){ + const w=ASG?.worksheets?.find(x=>x.id===mine.id); if(!w) return; + for(let i=0;ix.id===mine.id); if(!w) return; + button.disabled=true; + const url=await wsBlob(`/api/math/worksheets/${encodeURIComponent(w.id)}/original`); + if(WS!==mine) return url&&URL.revokeObjectURL(url); + button.disabled=false; + if(!url){ mine.msg="ההורדה נכשלה. נסו שוב."; return drawWorksheet(); } + const link=document.createElement("a"); link.href=url; link.download=`${w.title}.pdf`; document.body.append(link); link.click(); link.remove(); + setTimeout(()=>URL.revokeObjectURL(url),60000); +} +async function wsAddPhotos(input){ + const mine=WS, files=[...input.files]; input.value=""; + if(!mine||!files.length) return; + mine.msg="מכינים את התמונות…"; drawWorksheet(); + let failed=0; + for(const f of files){ + if(mine.files.length>=10){ mine.msg="אפשר עד 10 עמודים."; break; } + const blob=await wsPrepare(f); + if(WS!==mine) return; + if(!blob){ failed++; continue; } + mine.files.push(blob); mine.previews.push(URL.createObjectURL(blob)); + } + if(mine.files.length<10||failed) mine.msg=failed?`לא הצלחנו להכין ${failed===1?"תמונה אחת":`${failed} תמונות`}. צלמו שוב.`:""; + drawWorksheet(); +} +function wsMove(i,drop){ + if(!WS||WS.busy) return; + if(drop){ URL.revokeObjectURL(WS.previews[i]); WS.files.splice(i,1); WS.previews.splice(i,1); } + else if(i>0){ for(const k of ["files","previews"]) [WS[k][i-1],WS[k][i]]=[WS[k][i],WS[k][i-1]]; } + WS.msg=""; drawWorksheet(); +} +async function wsSend(){ + const mine=WS; if(!mine||mine.busy||!mine.files.length) return; + mine.busy=true; mine.progress=0; mine.msg="שולחים…"; drawWorksheet(); + const body=new FormData(); mine.files.forEach((f,i)=>body.append("files",f,`page${i+1}.jpg`)); + const r=await sendForm(`/api/math/worksheets/${encodeURIComponent(mine.id)}/submission`,"PUT",body,p=>{ + mine.progress=p; const bar=WS===mine&&sheet.querySelector(".mx-ws-hand progress"); if(bar) bar.value=p; }); + if(WS!==mine) return; + mine.busy=false; + if(!r.ok){ + mine.msg=wsError(r,"השליחה נכשלה. התמונות נשמרו כאן: נסו לשלוח שוב."); // the chosen photos stay + if(r.data?.detail==="already_checked") loadAssignments(); + return drawWorksheet(); + } + ASG.submissions=[...(ASG.submissions||[]).filter(x=>x.worksheet_id!==mine.id),r.data]; + for(const u of mine.previews) URL.revokeObjectURL(u); + mine.files=[]; mine.previews=[]; mine.msg="ההגשה נשלחה למורה. 🎉"; + drawWorksheet(); draw(); // the card on the home screen behind the sheet +} + +// the report's own controls: the view and the order function reportControl(t){ - if(t.dataset.reportTab){ REPORT.tab=t.dataset.reportTab; repSet("tab",REPORT.tab); drawReport(); sheet.querySelector(`[data-report-tab="${REPORT.tab}"]`)?.focus(); } + if(t.dataset.reportTab){ wsSubClose(); REPORT.tab=t.dataset.reportTab; repSet("tab",REPORT.tab); drawReport(); sheet.querySelector(`[data-report-tab="${REPORT.tab}"]`)?.focus(); } + else if(t.matches("[data-report-unit]")){ REPORT.unit=t.value; drawReport(); sheet.querySelector("[data-report-unit]")?.focus(); } + else if(t.dataset.heatGroup||t.dataset.heatAll){ + const open=heatOpen(), key=t.dataset.heatGroup; + if(key) open.has(key)?open.delete(key):open.add(key); + else if(t.dataset.heatAll==="close") open.clear(); + else [...sheet.querySelectorAll("[data-heat-group]")].forEach(b=>open.add(b.dataset.heatGroup)); + REPORT.open="|"+[...open].join("|"); repSet("mapMonths",REPORT.open); drawReport(); + sheet.querySelector(key?`[data-heat-group="${CSS.escape(key)}"]`:`[data-heat-all="${t.dataset.heatAll}"]`)?.focus(); } + else if(t.dataset.heatUnit){ REPORT.unit=t.dataset.heatUnit; drawReport(); sheet.querySelector("[data-report-unit]")?.focus(); } else if(t.matches("[data-report-sort]")){ REPORT.sort=t.value; repSet("sort",t.value); drawReport(); sheet.querySelector("[data-report-sort]")?.focus(); } - else if(t.matches("[data-report-unit]")){ REPORT.unit=t.value; repSet("unit",t.value); drawReport(); sheet.querySelector("[data-report-unit]")?.focus(); } } /* ---------- the teacher's review of a student's answer (app/teacher_review.py) ---------- @@ -1943,13 +2463,15 @@ const AQ_STATE={pending:"ממתינה",sending:"נשלחת…",done:"נשלחה const AQ_ERR={grading_failed:"הבדיקה נכשלה. אפשר לנסות שוב.",grading_unavailable:"בדיקה אוטומטית אינה זמינה כרגע באתר.",open_ended_locked:"שאלות פתוחות נעולות כרגע.", grading_limit:"התלמיד הגיע למכסת הבדיקות היומית.",grading_busy:"כבר מתבצעות בדיקות לתלמיד הזה. נסו שוב בעוד רג��.",grading_interrupted:"הבדיקה הופסקה. אפשר לנסות שוב.", request_taken:"הבקשה כבר טופלה (אולי ממכשיר אחר). רעננו את התור.",queue_unavailable:"התור אינו זמין כרגע.", - answer_storage_unavailable:"לא הצלחנו לקרוא את התמונה השמורה.",photo_failed:"פתרון התמונה נכשל. אפשר לנסות שוב."}; + answer_storage_unavailable:"לא הצלחנו לקרוא את התמונה השמורה.",photo_failed:"פתרון התמונה נכשל. אפשר לנסות שוב.", + teacher_ai_limit:"הגעתם למכסה של 3 בקשות AI ביום. אפשר לשלוח שוב מחר."}; // a failed send's error is "code|reason|technical detail" (app/ai_queue.py) function aqError(error){ const [code,reason="",...rest]=String(error||"").split("|"), detail=rest.join("|"); return `

    ${esc(AQ_ERR[code]||code||"השליחה נכשלה.")}

    ${window.MathlyAnswers?.errorDetails?.(reason,detail)||""}`; } const AQ_VERDICT={correct:"נכונה",partial:"נכונה חלקית",incorrect:"שגויה",unreadable:"לא קריאה",needs_review:"דורשת בדיקה שלכם"}; +const aqCode=list=>{ const c=list.map(r=>r.code).find(Boolean); return c?`#${esc(c)}`:""; }; const aqWaiting=r=>["pending","sending","failed"].includes(r.state); const aqLabel=()=>`תור AI${(AQ_OPEN||0)+RQ_OPEN?` ${(AQ_OPEN||0)+RQ_OPEN}`:""}`; // waiting requests, and students asking to look again function aqPaintBadge(){ document.querySelectorAll('[data-act="ai-queue"]').forEach(b=>{ if(!b.closest(".mx-sheet")) b.innerHTML=aqLabel(); }); } @@ -1961,7 +2483,7 @@ async function aqCount(){ } function dropQueue(){ if(AQ) Object.values(AQ.urls).forEach(u=>u&&URL.revokeObjectURL(u)); AQ=null; } async function aiQueue(){ - if(cur!=="aiqueue"){ if(!cur) back=document.activeElement; closeSheetQuietly(); cur="aiqueue"; AQ={rows:null,reqs:null,tab:AQ_TAB,urls:{},busy:new Set()}; } + if(cur!=="aiqueue"){ if(!cur) back=document.activeElement; closeSheetQuietly(); cur="aiqueue"; AQ={rows:null,reqs:null,tab:AQ_TAB,urls:{},busy:new Set(),openGroups:new Set()}; } const mine=AQ; sheet.innerHTML=`