Chapter 2 Relations and Functions

relations and functions

Last Updated:

Written by:

Time to read:

Advertisements

Ordered Pair Definition

An ordered pair of elements taken from any two sets P and Q is a pair of elements written in small brackets and grouped together in a particular order, i.e., (p,q), p ∈ P and q ∈ Q

For example, suppose A is a set of 2 colours and B is a set of 3 objects, i.e., A = {red, blue} and B = {b, c, s}, where b, c and s represent a particular bag, coat and shirt, respectively, then, Ordered Pairs of coloured objects that can be made from these two sets are:

(red, b), (red, c), (red, s), (blue, b), (blue, c), (blue, s)

Ordered pair definition

Cartesian Product of Sets

Given two non-empty sets P and Q. The cartesian product P × Q is the set of all ordered pairs of elements from P and Q, i.e., P × Q = { (p,q) : p ∈ P, q ∈ Q }

Above sentence simply means:

P × Q is the set of all ordered pairs (p, q) where the first element p is taken from set P and the second element q is taken from set Q.

In other words, we choose one element from P and one element from Q and write them in order as (p, q). All such ordered pairs together form the Cartesian product P × Q.

If either P or Q is the null set, then P × Q will also be empty set, i.e., P × Q = φ

Remarks

  1. Two ordered pairs are equal, if and only if the corresponding first elements are equal and the second elements are also equal.
  2. If there are p elements in A and q elements in B, then there will be pq elements in A × B, i.e., if n(A) = p and n(B) = q, then n(A × B) = pq
  3. If A and B are non-empty sets and either A or B is an infinite set, then so is A × B.
  4. A × A × A = {(a, b, c) : a, b, c ∈ A}. Here (a, b, c) is called an ordered triplet. Means the set of all ordered triplets where each of the three entries is taken from set A.

Examples of Cartesian Product of Sets

Example 1

Consider the two sets: A = {DL, MP, KA}, where DL, MP, KA represent Delhi, Madhya Pradesh and Karnataka, respectively B = {01,02,03} representing codes for the licence plates of vehicles issued by DL, MP and KA. If the three states, Delhi, Madhya Pradesh and Karnataka were making codes for the licence plates of vehicles, with the restriction that the code begins with an element from set A, which are the pairs available from these sets then the available pairs are:(DL,01), (DL,02), (DL,03), (MP,01), (MP,02), (MP,03), (KA,01), (KA,02), (KA,03) and the product of set A and set B is given by A × B = {(DL,01), (DL,02), (DL,03), (MP,01), (MP,02), (MP,03), (KA,01), (KA,02), (KA,03)}. It can easily be seen that there will be 9 such pairs in the Cartesian product, since there are 3 elements in each of the sets A and B. This gives us 9 possible codes. Also note that the order in which these elements are paired is crucial. For example, the code (DL, 01) will not be the same as the code (01, DL).

example of cartesian product of sets

Example 2

Consider the two sets A= {a1 , a2 } and B = {b1 , b2 , b3 , b4 } .

A × B = {( a1 , b1 ), (a1 , b2 ), (a1 , b3 ), (a1 , b4 ), (a2 , b1 ), (a2 , b2 ), (a2 , b3 ), (a2 , b4 )}.

The 8 ordered pairs thus formed can represent the position of points in the plane if A and B are subsets of the set of real numbers and it is obvious that the point in the position (a1 , b2 ) will be distinct from the point in the position (b2 , a1 ).

example of cartesian product of sets

Relations, Image, Domain, Range, Codomain

A relation R from a non-empty set A to a non-empty set B is a subset of the cartesian product A × B. The subset is derived by describing a relationship between the first element and the second element of the ordered pairs in A × B. The second element is called the image of the first element

The set of all first elements of the ordered pairs in a relation R from a set A to a set B is called the domain of the relation R.

The set of all second elements in a relation R from a set A to a set B is called the range of the relation R. The whole set B is called the codomain of the relation R. Note that range ⊂ codomain.

A relation may be represented algebraically either by the Roster method or by the Set-builder method. An arrow diagram is a visual representation of a relation.

A relation R from A to A is also stated as a relation on A.

Example of a relation

Consider the two sets P= {a, b, c} and Q = {Ali, Bhanu, Binoy, Chandra, Divya}.

The cartesian product of P and Q has 15 ordered pairs which can be listed as P × Q = {(a, Ali), (a, Bhanu), (a, Binoy), …, (c, Divya)}.

We can now obtain a subset of P x Q by introducing a relation R between the first element x and the second element y of each ordered pair (x,y) as

R = { (x,y) : x is the first letter of the name y, x ϵ P, y ϵ Q }

Then, R = { (a, Ali), (b, Bhanu), (b, Binoy), (c, Chandra) }

A visual representation of this relation R (called an arrow diagram) is shown in the image

The total number of relations that can be defined from a set A to a set B is the number of possible subsets of A × B. If n(A) = p and n(B) = q, then

n (A x B) = pq and the total number of relations is 2pq

Example of a relation

Functions

A relation f from a set A to a set B is said to be a function if every element of set A has one and only one image in set B.

In other words, a function f is a relation from a non-empty set A to a non-empty set B such that the domain of f is A and no two distinct ordered pairs in f have the same first element. If f is a function from A to B and (a, b) ∈ f, then f (a) = b, where b is called the image of a under f and a is called the preimage of b under f.

The function f from A to B is denoted by f: A → B. A is called the domain of f and B is called the co-domain of f

Real valued function and real function

A function which has either R or one of its subsets as its range is called a real valued function. Further, if its domain is also either R or a subset of R, it is called a real function.

Some functions and their graphs

(i) Identity Function :

Let R be the set of real numbers. Define the real valued function f : R → R by y = f(x) = x for each x ∈ R. Such a function is called the identity function. Here the domain and range of f are R. The graph is a straight line. It passes through the origin

Identity Function

(ii) Constant Function :

Define the function f: R → R by y = f (x) = c, x ∈ R where c is a constant and each x ∈ R. Here domain of f is R and its range is {c}. The graph is a line parallel to x-axis. For example, if f(x)=3 for each x∈R

Identity Function

(iii) Polynomial Function:

A function f : R → R is said to be polynomial function if for each x in R, y = f (x) = a0 + a1 x + a2 x2 +…an xn , where n is a non-negative integer and a0, a1, a2,…an 𝛜 R

(iv) Rational Functions:

Rational functions are functions of the type f(x)/g(x), where f(x) and g(x) are polynomial functions of x defined in a domain, where g(x) ≠ 0

(v) Modulus Function:

The function f: R→R defined by f(x) = |x| for each x ∈R is called modulus function. For each non-negative value of x, f(x) is equal to x.

But for negative values of x, the value of f(x) is the negative of the value of x, i.e.,

modulus function
Modulus Function

(vi) Signum Function:

The function f:R→R defined by:

signum function

is called the signum function. The domain of the signum function is R and the range is the set {–1, 0, 1}.

Signum Function

(vii) Greatest Integer Function:

The function f: R → R defined by f(x) = [x], x ∈R assumes the value of the greatest integer, less than or equal to x. Such a function is called the greatest integer function. From the definition of [x], we can see that
[x] = –1 for –1 ≤ x < 0
[x] = 0 for 0 ≤ x < 1
[x] = 1 for 1 ≤ x < 2
[x] = 2 for 2 ≤ x < 3 and so on

greatest integer function

(viii) Linear Function:

The function f defined by f(x) = mx + c , x ∈ R, is called linear function, where m and c are constants. 

Example: f : R→R by f(x) = x + 10, R is the set of real numbers

Algebra of real functions

(i) Addition of two real functions : Let f : X → R and g : X → R be any two real functions, where X ⊂ R. Then, we define (f + g): X → R by (f + g) (x) = f (x) + g (x), for all x ∈ X.

(ii) Subtraction of a real function from another : Let f : X → R and g: X → R be any two real functions, where X ⊂ R. Then, we define (f – g) : X→R by (f–g) (x) = f(x) – g(x), for all x ∈ X.

(iii) Multiplication by a scalar : Let f : X→R be a real valued function and α be a scalar. Here by scalar, we mean a real number. Then the product α f is a function from X to R defined by (α f ) (x) = α f (x), x ∈X.

(iv) Multiplication of two real functions : The product (or multiplication) of two real functions f:X→R and g:X→R is a function fg:X→R defined by (fg) (x) = f(x) g(x), for all x ∈ X.

This is also called pointwise multiplication.

(v) Quotient of two real functions : Let f and g be two real functions defined from X→R, where X ⊂ R.

The quotient of f by g denoted by f/g is a function defined by,

NOTE:

Domain of sum function f + g, difference function f − g and product function fg

= { x : x ∈ Df ∩ Dg }

where

Df = Domain of function f
Dg = Domain of function g

Domain of quotient function (f / g):

= { x : x ∈ Df ∩ Dg and g(x) ≠ 0 }

`;popup.document.open(); popup.document.write(html); popup.document.close(); };function formatFeeMoney(minor,currency="INR"){ const amount=Number(minor||0)/100;try{ return new Intl.NumberFormat("en-IN",{ style:"currency", currency:currency||"INR", minimumFractionDigits:0, maximumFractionDigits:2 }).format(amount); }catch(_){ return `${currency||"INR"} ${amount.toFixed(2)}`; } }function feePaymentsForCharge(chargeId){ return feePayments .filter(payment=>payment.charge_id===chargeId) .sort( (a,b)=> String(b.paid_at||"") .localeCompare(String(a.paid_at||"")) ); }function feeDisplayStatus(charge){ if(!charge)return "pending";if( charge.status==="pending" || charge.status==="partial" ){ const due=new Date(`${charge.due_date}T23:59:59`); if(due.getTime() ["pending","partial"].includes(charge.status) ?sum+Number(charge.balance_amount_minor||0) :sum, 0 ); }function feeOverdueCount(){ return feeCharges.filter( charge=>feeDisplayStatus(charge)==="overdue" ).length; }function feeReceivedThisMonthMinor(){ const now=new Date(); const y=now.getFullYear(); const m=now.getMonth();return feePayments .filter(payment=>{ if(payment.status!=="received")return false;const d=new Date(payment.paid_at); return ( d.getFullYear()===y && d.getMonth()===m ); }) .reduce( (sum,payment)=> sum+Number(payment.amount_minor||0), 0 ); }function billingMonthLabel(value){ if(!value)return "";const d=new Date(`${value.slice(0,7)}-01T00:00:00`);return d.toLocaleDateString( "en-IN", { month:"long", year:"numeric" } ); }function feeGroupLabel(groupId){ if(!groupId)return "General tuition";const group=academicGroups.find( g=>g.id===groupId );return group?.name||"Academic Group"; }function renderFees(main){ const isAdmin=me.role==="admin";main.innerHTML=`
Fees & Receipts
Regular academy tuition fees and payment receipts.
${isAdmin?` `:""}
${formatFeeMoney(feeOutstandingMinor())} Outstanding balance
${feeOverdueCount()} Overdue fee record${feeOverdueCount()===1?"":"s"}
${formatFeeMoney(feeReceivedThisMonthMinor())} ${isAdmin?"Received this month":"Paid this month"}
`;const createBtn=main.querySelector("#uaCreateFeeCharge"); if(createBtn){ createBtn.onclick=openCreateFeeCharge; }const filter=main.querySelector("#uaFeeFilter"); filter.onchange=()=>{ renderFeeCards( main.querySelector("#uaFeeCards"), filter.value ); };renderFeeCards( main.querySelector("#uaFeeCards"), "all" ); }function renderFeeCards(container,filter){ let rows=[...feeCharges];if(filter==="pending"){ rows=rows.filter( charge=> charge.status==="pending" || charge.status==="partial" ); }else if(filter==="overdue"){ rows=rows.filter( charge=>feeDisplayStatus(charge)==="overdue" ); }else if(filter!=="all"){ rows=rows.filter( charge=>charge.status===filter ); }rows.sort((a,b)=>{ const statusRank={ overdue:4, partial:3, pending:2, paid:1, waived:0, cancelled:-1 };const sa=statusRank[feeDisplayStatus(a)]??0; const sb=statusRank[feeDisplayStatus(b)]??0;if(sb!==sa)return sb-sa;return String(b.billing_month) .localeCompare(String(a.billing_month)); });container.innerHTML=rows.length?`
${rows.map(charge=>{ const student=profiles.find( p=>p.id===charge.student_id );const displayStatus=feeDisplayStatus(charge);return `
${esc(student?.full_name||student?.email||"Student")}
${esc(billingMonthLabel(charge.billing_month))} · ${esc(feeGroupLabel(charge.group_id))}
${esc(feeStatusLabel(charge))}
${formatFeeMoney(charge.balance_amount_minor,charge.currency)}
${displayStatus==="paid"?"No balance due":"Balance due"}
Fee
${formatFeeMoney(charge.gross_amount_minor,charge.currency)}
Discount
${formatFeeMoney(charge.discount_minor,charge.currency)}
Paid
${formatFeeMoney(charge.paid_amount_minor,charge.currency)}
Due: ${esc(charge.due_date)}
`; }).join("")}
`:`
No fee records in this view.
`; }function activeStudentProfiles(){ return profiles.filter( p=> isStudentProfile(p) && p.is_active!==false ); }function groupsForStudent(studentId){ const ids=new Set( academicGroupMembers .filter( row=> row.user_id===studentId && row.membership_role==="student" ) .map(row=>row.group_id) );return academicGroups.filter( group=> ids.has(group.id) && group.is_active!==false ); }function monthInputValue(date=new Date()){ const y=date.getFullYear(); const m=String(date.getMonth()+1).padStart(2,"0"); return `${y}-${m}`; }function dateInputValue(date=new Date()){ const y=date.getFullYear(); const m=String(date.getMonth()+1).padStart(2,"0"); const d=String(date.getDate()).padStart(2,"0"); return `${y}-${m}-${d}`; }function openCreateFeeCharge(){ const students=activeStudentProfiles();if(!students.length){ return toast("No Student account is available."); }modal("Add Monthly Tuition Fee",`
`);const studentSelect=document.getElementById("uaFeeStudent"); const groupSelect=document.getElementById("uaFeeGroup");function syncGroups(){ const groups=groupsForStudent(studentSelect.value);groupSelect.innerHTML= ``+ groups.map(group=>` `).join(""); }studentSelect.addEventListener("change",syncGroups); syncGroups();document.getElementById("uaSaveFeeCharge").onclick=function(){ createFeeCharge(this); }; }async function createFeeCharge(button){ const studentId= document.getElementById("uaFeeStudent").value;const groupId= document.getElementById("uaFeeGroup").value||null;const month= document.getElementById("uaFeeMonth").value;const due= document.getElementById("uaFeeDue").value;const description= document.getElementById("uaFeeDescription").value.trim();const gross= Number(document.getElementById("uaFeeGross").value||0);const discount= Number(document.getElementById("uaFeeDiscount").value||0);const note= document.getElementById("uaFeeNote").value.trim();if(!studentId||!month||!due){ return toast("Student, billing month and due date are required."); }if(!Number.isFinite(gross)||gross<0){ return toast("Enter a valid monthly fee."); }if( !Number.isFinite(discount) || discount<0 || discount>gross ){ return toast("Discount cannot exceed the monthly fee."); }const restore=setButtonBusy(button,"Creating…");try{ const result=await rest( "rpc/admin_create_tuition_fee_charge", { method:"POST", body:{ p_student_id:studentId, p_group_id:groupId, p_billing_month:`${month}-01`, p_due_date:due, p_gross_amount_minor:Math.round(gross*100), p_discount_minor:Math.round(discount*100), p_description:description||"Monthly tuition fee", p_note:note||null } } );restore(); closeModal(); await refresh();if(result?.charge_id){ window.UAopenFeeCharge(result.charge_id); }else{ renderMain("fees"); }toast("Fee record created."); }catch(e){ toast(e.message); }finally{ restore(); } }window.UAopenFeeCharge=function(chargeId){ const charge=feeCharges.find( c=>c.id===chargeId );if(!charge)return;const student=profiles.find( p=>p.id===charge.student_id );const chargePayments= feePaymentsForCharge(charge.id);const displayStatus=feeDisplayStatus(charge);let body=`
Student ${esc(student?.full_name||student?.email||"Student")}
Month ${esc(billingMonthLabel(charge.billing_month))}
Class / Group ${esc(feeGroupLabel(charge.group_id))}
Status ${esc(feeStatusLabel(charge))}
Due date ${esc(charge.due_date)}
Description ${esc(charge.description)}
Monthly fee
${formatFeeMoney(charge.gross_amount_minor,charge.currency)}
Discount
${formatFeeMoney(charge.discount_minor,charge.currency)}
Net fee
${formatFeeMoney(charge.net_amount_minor,charge.currency)}
Paid
${formatFeeMoney(charge.paid_amount_minor,charge.currency)}
Balance
${formatFeeMoney(charge.balance_amount_minor,charge.currency)}
${charge.note?`
${esc(charge.note)}
`:""}
Payments & Receipts${chargePayments.length ?chargePayments.map(payment=>`
${esc(payment.receipt_number)}
${formatDateTime(payment.paid_at)} · ${esc(payment.payment_method.replace("_"," "))} ${payment.reference?` · ${esc(payment.reference)}`:""}
${payment.status==="reversed"?`
Reversed ${payment.reversal_reason?` · ${esc(payment.reversal_reason)}`:""}
`:""}
${formatFeeMoney(payment.amount_minor,payment.currency)}
${me.role==="admin"&&payment.status==="received"?` `:""}
`).join("") :`
No payment recorded yet.
` }
`;if( me.role==="admin" && ["pending","partial"].includes(charge.status) && Number(charge.balance_amount_minor)>0 ){ body+=`
${Number(charge.paid_amount_minor)===0?` `:""}
`; }modal("Fee Details",body); };window.UArecordFeePayment=function(chargeId){ const charge=feeCharges.find( c=>c.id===chargeId );if(!charge||me.role!=="admin")return;closeModal();modal("Record Tuition Fee Payment",`
Outstanding balance: ${formatFeeMoney(charge.balance_amount_minor,charge.currency)}
`);document.getElementById("uaSaveFeePayment").onclick=function(){ saveFeePayment(chargeId,this); }; };async function saveFeePayment(chargeId,button){ const charge=feeCharges.find( c=>c.id===chargeId );if(!charge)return;const amount= Number(document.getElementById("uaFeePayAmount").value||0);const method= document.getElementById("uaFeePayMethod").value;const date= document.getElementById("uaFeePayDate").value;const reference= document.getElementById("uaFeePayReference").value.trim();const note= document.getElementById("uaFeePayNote").value.trim();const amountMinor=Math.round(amount*100);if( !Number.isFinite(amount) || amount<=0 ){ return toast("Enter a valid payment amount."); }if(amountMinor>Number(charge.balance_amount_minor)){ return toast("Payment cannot exceed the outstanding balance."); }const paidAt=date ?new Date(`${date}T12:00:00`).toISOString() :new Date().toISOString();const restore=setButtonBusy(button,"Recording…");try{ const result=await rest( "rpc/admin_record_tuition_fee_payment", { method:"POST", body:{ p_charge_id:chargeId, p_amount_minor:amountMinor, p_payment_method:method, p_reference:reference||null, p_paid_at:paidAt, p_note:note||null } } );restore(); closeModal(); await refresh();window.UAopenFeeCharge(chargeId);toast( result?.receipt_number ?`Payment recorded. Receipt ${result.receipt_number}` :"Payment recorded." ); }catch(e){ toast(e.message); }finally{ restore(); } }window.UAreverseFeePayment=function(paymentId){ const payment=feePayments.find( p=>p.id===paymentId );if(!payment||me.role!=="admin")return;closeModal();modal("Reverse Payment",`
Receipt: ${esc(payment.receipt_number)}
Amount: ${formatFeeMoney(payment.amount_minor,payment.currency)}
The receipt remains in history and will be marked Reversed.
`);document.getElementById("uaConfirmFeeReverse").onclick=function(){ reverseFeePayment(paymentId,this); }; };async function reverseFeePayment(paymentId,button){ const payment=feePayments.find( p=>p.id===paymentId );if(!payment)return;const reason= document.getElementById("uaFeeReverseReason").value.trim();const restore=setButtonBusy(button,"Reversing…");try{ await rest( "rpc/admin_reverse_tuition_fee_payment", { method:"POST", body:{ p_payment_id:paymentId, p_reason:reason||"Admin correction" } } );restore(); closeModal(); await refresh(); window.UAopenFeeCharge(payment.charge_id); toast("Payment reversed."); }catch(e){ toast(e.message); }finally{ restore(); } }window.UAcancelFeeCharge=function(chargeId){ if(me.role!=="admin")return;closeModal();modal("Cancel Fee Record",`
Use this only for a fee record created by mistake.
`);document.getElementById("uaConfirmFeeCancel").onclick=function(){ cancelFeeCharge(chargeId,this); }; };async function cancelFeeCharge(chargeId,button){ const reason= document.getElementById("uaFeeCancelReason").value.trim();const restore=setButtonBusy(button,"Cancelling…");try{ await rest( "rpc/admin_cancel_tuition_fee_charge", { method:"POST", body:{ p_charge_id:chargeId, p_reason:reason||null } } );restore(); closeModal(); await refresh(); renderMain("fees"); toast("Fee record cancelled."); }catch(e){ toast(e.message); }finally{ restore(); } }window.UAprintFeeReceipt=function(paymentId){ const payment=feePayments.find( p=>p.id===paymentId );if(!payment)return;const charge=feeCharges.find( c=>c.id===payment.charge_id );if(!charge)return;const student=profiles.find( p=>p.id===charge.student_id );const receiptWindow=window.open( "", "_blank", "noopener,noreferrer" );if(!receiptWindow){ return toast("Allow pop-ups to open the receipt."); }const studentName= student?.full_name||student?.email||"Student";const paymentDate= new Date(payment.paid_at) .toLocaleDateString("en-IN",{ day:"2-digit", month:"long", year:"numeric" });const method= payment.payment_method .replace("_"," ") .replace(/\b\w/g,c=>c.toUpperCase());const receiptHtml=` ${esc(payment.receipt_number)}

unipolaris academy

Tuition fee receipt

${payment.status==="reversed"?`
REVERSED RECEIPT
`:""}
Receipt no. ${esc(payment.receipt_number)}
Date ${esc(paymentDate)}
Student ${esc(studentName)}
Billing month ${esc(billingMonthLabel(charge.billing_month))}
Fee for ${esc(charge.description)}
Payment method ${esc(method)}
${payment.reference?`
Reference ${esc(payment.reference)}
`:""}
Amount received ${esc(formatFeeMoney(payment.amount_minor,payment.currency))}
Total fee after discount ${esc(formatFeeMoney(charge.net_amount_minor,charge.currency))}
Balance after this payment* ${esc(formatFeeMoney(charge.balance_amount_minor,charge.currency))}
`;receiptWindow.document.open(); receiptWindow.document.write(receiptHtml); receiptWindow.document.close(); };function announcementReadByMe(announcementId){ return announcementReads.some( row=> row.announcement_id===announcementId && row.user_id===me.id ); }function announcementReadCount(announcementId){ return announcementReads.filter( row=>row.announcement_id===announcementId ).length; }function announcementAudienceLabel(a){ if(a.audience_type==="all"){ return "Entire academy"; }if(a.audience_type==="group"){ const group=academicGroups.find(g=>g.id===a.group_id); return group?.name||"Academic Group"; }const count=announcementRecipients.filter( row=>row.announcement_id===a.id ).length;return `${count} selected user${count===1?"":"s"}`; }function announcementIsCurrent(a){ const now=Date.now();if( a.starts_at && new Date(a.starts_at).getTime()>now ){ return false; }if( a.expires_at && new Date(a.expires_at).getTime()<=now ){ return false; }return true; }function canManageAnnouncement(a){ return ( me.role==="admin" || ( me.role==="teacher" && a.created_by===me.id ) ); }function unreadAnnouncementCount(){ return announcements.filter( a=> a.is_published!==false && announcementIsCurrent(a) && !announcementReadByMe(a.id) && !canManageAnnouncement(a) ).length; }function renderAnnouncements(main){ const canCreate= me.role==="admin" || me.role==="teacher";const priorityRank={ urgent:3, important:2, normal:1 };const sorted=[ ...announcements ].sort((a,b)=>{ const p= (priorityRank[b.priority]||0)- (priorityRank[a.priority]||0);if(p)return p;return String(b.created_at) .localeCompare(String(a.created_at)); });main.innerHTML=`
Announcements
Academy notices, class updates and important reminders.
${canCreate?` `:""}
${sorted.length?`
${sorted.map(a=>{ const unread= !announcementReadByMe(a.id) && !canManageAnnouncement(a);const scheduled= a.starts_at && new Date(a.starts_at).getTime()>Date.now();const expired= a.expires_at && new Date(a.expires_at).getTime()<=Date.now();return `
${unread?``:""} ${esc(a.title)}
${formatDateTime(a.created_at)}
${esc(a.priority)}
${esc(a.message)}
${esc(announcementAudienceLabel(a))} ${a.is_published===false?` Draft `:""}${scheduled?` Scheduled `:""}${expired?` Expired `:""}
${canManageAnnouncement(a)?`
${announcementReadCount(a.id)} read
`:""}
`; }).join("")}
` :`
No announcements yet.
` } `;const addBtn=main.querySelector("#uaAddAnnouncement"); if(addBtn){ addBtn.onclick=openCreateAnnouncement; } }function teacherAnnouncementGroups(){ if(me.role==="admin"){ return academicGroups.filter( g=>g.is_active!==false ); }const groupIds=new Set( academicGroupMembers .filter( m=> m.user_id===me.id && m.membership_role==="teacher" ) .map(m=>m.group_id) );return academicGroups.filter( g=> groupIds.has(g.id) && g.is_active!==false ); }function openCreateAnnouncement(){ const groups=teacherAnnouncementGroups();if(me.role==="teacher"&&!groups.length){ return toast( "You are not assigned to an Academic Group." ); }const admin=me.role==="admin";modal("New Announcement",`
${admin?` `:""}
`);const audience=document.getElementById("uaAnnAudience"); const groupWrap=document.getElementById("uaAnnGroupWrap"); const usersWrap=document.getElementById("uaAnnUsersWrap");audience.onchange=()=>{ if(groupWrap){ groupWrap.style.display= audience.value==="group"?"block":"none"; }if(usersWrap){ usersWrap.style.display= audience.value==="users"?"block":"none"; } };document.getElementById("uaSaveAnnouncement").onclick=function(){ createAnnouncement(this); }; }async function createAnnouncement(button){ const title= document.getElementById("uaAnnTitle").value.trim();const message= document.getElementById("uaAnnMessage").value.trim();const priority= document.getElementById("uaAnnPriority").value;const audience= document.getElementById("uaAnnAudience").value;const groupId= audience==="group" ?document.getElementById("uaAnnGroup")?.value||null :null;const url= document.getElementById("uaAnnUrl").value.trim();const startRaw= document.getElementById("uaAnnStart").value;const expiryRaw= document.getElementById("uaAnnExpiry").value;const published= document.getElementById("uaAnnPublished").checked;let userIds=[];if(audience==="users"){ userIds=[ ...document.getElementById("uaAnnUsers").selectedOptions ].map(o=>o.value); }if(!title||!message){ return toast("Title and message are required."); }if(audience==="group"&&!groupId){ return toast("Select an Academic Group."); }if(audience==="users"&&!userIds.length){ return toast("Select at least one user."); }const startsAt= startRaw?new Date(startRaw).toISOString():null;const expiresAt= expiryRaw?new Date(expiryRaw).toISOString():null;if( startsAt && expiresAt && new Date(expiresAt).getTime()<= new Date(startsAt).getTime() ){ return toast("Expiry must be after the start time."); }const restore=setButtonBusy(button,"Saving…"); let announcement=null;try{ const rows=await rest("announcements",{ method:"POST", body:{ title, message, priority, audience_type:audience, group_id:groupId, external_url:url||null, starts_at:startsAt, expires_at:expiresAt, is_published:published, created_by:me.id }, prefer:"return=representation" });announcement=rows?.[0];if(!announcement){ throw new Error( "Announcement could not be created." ); }if(audience==="users"&&userIds.length){ await rest("announcement_recipients",{ method:"POST", body:userIds.map(user_id=>({ announcement_id:announcement.id, user_id })), prefer:"return=minimal" }); }restore(); closeModal(); await refresh(); renderMain("announcements"); toast("Announcement saved."); }catch(e){ if(announcement?.id){ try{ await rest( `announcements?id=eq.${announcement.id}`, { method:"DELETE", prefer:"return=minimal" } ); }catch(_){} }toast(e.message); }finally{ restore(); } }async function markAnnouncementRead(announcementId){ if(announcementReadByMe(announcementId)){ return; }try{ const readAt=new Date().toISOString();await rest( "announcement_reads?on_conflict=announcement_id,user_id", { method:"POST", body:{ announcement_id:announcementId, user_id:me.id, read_at:readAt }, prefer:"resolution=merge-duplicates,return=minimal" } );announcementReads.push({ announcement_id:announcementId, user_id:me.id, read_at:readAt }); }catch(_){ // The notice still opens if read tracking fails. } }window.UAopenAnnouncement=async function(announcementId){ const a=announcements.find( x=>x.id===announcementId );if(!a)return;if(!canManageAnnouncement(a)){ await markAnnouncementRead(a.id); }let body=`
${esc(a.title)}
${formatDateTime(a.created_at)}
${esc(a.priority)}
${esc(a.message)}
${esc(announcementAudienceLabel(a))} ${a.starts_at?` From ${formatDateTime(a.starts_at)} `:""}${a.expires_at?` Until ${formatDateTime(a.expires_at)} `:""}
${a.external_url?`
`:""} `;if(canManageAnnouncement(a)){ body+=`
${announcementReadCount(a.id)} read receipt${announcementReadCount(a.id)===1?"":"s"}
`; }modal("Announcement",body);const linkBtn=document.getElementById("uaAnnOpenLink");if(linkBtn){ linkBtn.onclick=()=>{ window.open( a.external_url, "_blank", "noopener,noreferrer" ); }; } };window.UAtoggleAnnouncementPublish=async function( announcementId, button ){ const a=announcements.find( x=>x.id===announcementId );if(!a||!canManageAnnouncement(a)){ return; }const restore=setButtonBusy( button, a.is_published?"Unpublishing…":"Publishing…" );try{ await rest( `announcements?id=eq.${announcementId}`, { method:"PATCH", body:{ is_published:!a.is_published }, prefer:"return=minimal" } );restore(); closeModal(); await refresh(); renderMain("announcements"); toast( a.is_published ?"Announcement unpublished." :"Announcement published." ); }catch(e){ toast(e.message); }finally{ restore(); } };window.UAdeleteAnnouncement=async function( announcementId, button ){ if(!confirm("Delete this announcement permanently?")){ return; }const restore=setButtonBusy(button,"Deleting…");try{ await rest( `announcements?id=eq.${announcementId}`, { method:"DELETE", prefer:"return=minimal" } );restore(); closeModal(); await refresh(); renderMain("announcements"); toast("Announcement deleted."); }catch(e){ toast(e.message); }finally{ restore(); } };function renderSavedArticles(main){ main.innerHTML=`
Saved Articles
Articles you bookmarked while browsing unipolaris.com.
${savedArticles.length?` ${savedArticles.length} saved `:""}
${savedArticles.length?`
${savedArticles.map(article=>`
${article.article_image_url?` `:""}
${esc(article.article_title)}
${article.article_excerpt?`
${esc(article.article_excerpt)}
`:""}
Saved ${formatDateTime(article.saved_at)}
`).join("")}
` :`
No saved articles yet.

Open an article on unipolaris.com and use the Save article button.
` } `; }window.UAopenSavedArticle=function(encodedUrl){ const url=decodeURIComponent(encodedUrl); window.open(url,"_blank","noopener,noreferrer"); };window.UAremoveSavedArticle=async function(articleId,button){ const restore=setButtonBusy(button,"Removing…");try{ await rest(`saved_articles?id=eq.${articleId}`,{ method:"DELETE", prefer:"return=minimal" });restore(); await refresh(); renderMain("saved"); toast("Article removed."); }catch(e){ toast(e.message); }finally{ restore(); } };function stopAssessmentTimer(){ if(assessmentTimerId){ clearInterval(assessmentTimerId); assessmentTimerId=null; } }function assessmentTypeLabel(type){ return ({ lesson_quiz:"Lesson quiz", chapter_test:"Chapter test", class_test:"Class test", practice_test:"Practice test" })[type]||"Test"; }function assessmentGroupIds(assessmentId){ return assessmentGroups .filter(x=>x.assessment_id===assessmentId) .map(x=>x.group_id); }function questionsForAssessment(assessmentId){ return assessmentQuestions .filter(x=>x.assessment_id===assessmentId) .sort((a,b)=>(a.position||0)-(b.position||0)); }function attemptsForAssessment(assessmentId,studentId=null){ return assessmentAttempts.filter(a=> a.assessment_id===assessmentId && (!studentId||a.student_id===studentId) ); }function assessmentQuestionCount(assessmentId){ return questionsForAssessment(assessmentId).length; }function assessmentMaxMarks(assessmentId){ return questionsForAssessment(assessmentId) .reduce((sum,x)=>sum+Number(x.marks||0),0); }function latestStudentAttempt(assessmentId,studentId=me.id){ return attemptsForAssessment(assessmentId,studentId) .sort((a,b)=>String(b.started_at).localeCompare(String(a.started_at)))[0]||null; }function canManageAssessment(a){ return ( me.role==="admin" || ( me.role==="teacher" && a.created_by===me.id ) ); }function lessonLabel(lessonId){ const lesson=courseLessons.find(l=>l.id===lessonId); if(!lesson)return "Lesson"; const section=courseSections.find(s=>s.id===lesson.section_id); const course=courses.find(c=>c.id===section?.course_id);return [course?.title,section?.title,lesson.title] .filter(Boolean) .join(" · "); }function assessmentTargetLabel(a){ if(a.lesson_id){ return lessonLabel(a.lesson_id); }const labels=assessmentGroupIds(a.id) .map(id=>groupName(id)) .filter(Boolean);return labels.join(", ")||"Academic group"; }function assessmentAvailabilityLabel(a){ const now=Date.now();if(a.available_from && nownew Date(a.available_until).getTime()){ return "Closed"; }return "Available"; }function renderAssessments(main){ const canCreate=me.role==="admin"||me.role==="teacher";main.innerHTML=`
Tests & Quizzes
Lesson quizzes, chapter tests and reusable question bank.
${canCreate?`
`:""}
${assessments.length?`
${assessments.map(a=>{ const latest=isLearner() ?latestStudentAttempt(a.id) :null; const submitted=attemptsForAssessment(a.id) .filter(x=>x.status==="submitted"); const avg=submitted.length ?Math.round( submitted.reduce( (sum,x)=>sum+Number(x.percentage||0), 0 )/submitted.length ) :null;return `
${esc(a.title)}
${esc(assessmentTargetLabel(a))}
${esc(assessmentTypeLabel(a.assessment_type))}
${a.description?`
${esc(a.description)}
`:""}
${assessmentQuestionCount(a.id)} question${assessmentQuestionCount(a.id)===1?"":"s"} ${assessmentMaxMarks(a.id)} marks ${a.time_limit_minutes?` ${a.time_limit_minutes} min `:""}${a.is_published===false?` Draft`:""}
${ isLearner() ?latest?.status==="submitted" ?`Latest: ${Number(latest.percentage||0).toFixed(0)}% · ${latest.passed?"Passed":"Not passed"}` :assessmentAvailabilityLabel(a) :submitted.length ?`${submitted.length} submitted · ${avg}% average` :"No submissions yet" }
`; }).join("")}
` :`
${canCreate ?"No tests or quizzes have been created yet." :"No test or quiz is assigned to your account yet."}
` } `;if(canCreate){ main.querySelector("#uaQuestionBankBtn").onclick=openQuestionBank; main.querySelector("#uaAddAssessment").onclick=openCreateAssessment; } }function manageableLessons(){ return courseLessons.filter(lesson=>{ const section=courseSections.find(s=>s.id===lesson.section_id); const course=courses.find(c=>c.id===section?.course_id); return course && canManageCourse(course); }); }function openCreateAssessment(){ const groups=academicGroups.filter(g=>g.is_active!==false); const lessons=manageableLessons();modal("Create test / quiz",`
If a lesson is selected, access is inherited from that course.
After saving, open the test and add questions from the Question Bank.
`);const typeSelect=document.getElementById("uaAType"); const lessonSelect=document.getElementById("uaALesson");typeSelect.addEventListener("change",()=>{ if(typeSelect.value==="lesson_quiz" && !lessonSelect.value && lessons.length){ lessonSelect.value=lessons[0].id; } });lessonSelect.addEventListener("change",()=>{ if(lessonSelect.value){ typeSelect.value="lesson_quiz"; } });document.getElementById("uaSaveAssessment").onclick=function(){ createAssessment(this); }; }async function createAssessment(button){ const title=document.getElementById("uaATitle").value.trim(); const description=document.getElementById("uaADescription").value.trim(); const type=document.getElementById("uaAType").value; const lessonId=document.getElementById("uaALesson").value||null; const timeRaw=document.getElementById("uaATime").value.trim(); const attemptsRaw=document.getElementById("uaAAttempts").value.trim(); const passRaw=document.getElementById("uaAPass").value.trim(); const published=document.getElementById("uaAPublished").checked; const groupIds=[ ...document.getElementById("uaAGroups").selectedOptions ].map(o=>o.value);if(!title)return toast("Enter a test title.");if(!lessonId&&!groupIds.length){ return toast("Select at least one Academic Group or attach the quiz to a lesson."); }const timeLimit=timeRaw?Number(timeRaw):null; const maxAttempts=attemptsRaw?Number(attemptsRaw):null; const passPercent=passRaw===""?40:Number(passRaw);if(timeLimit!==null&&(!Number.isInteger(timeLimit)||timeLimit<1)){ return toast("Time limit must be a whole number of minutes."); }if(maxAttempts!==null&&(!Number.isInteger(maxAttempts)||maxAttempts<1)){ return toast("Maximum attempts must be a whole number."); }if(!Number.isFinite(passPercent)||passPercent<0||passPercent>100){ return toast("Pass percentage must be between 0 and 100."); }const restore=setButtonBusy(button,"Creating…"); let assessment=null;try{ const rows=await rest("assessments",{ method:"POST", body:{ title, description:description||null, assessment_type:lessonId?"lesson_quiz":type, lesson_id:lessonId, time_limit_minutes:timeLimit, max_attempts:maxAttempts, pass_percent:passPercent, created_by:me.id, is_published:published }, prefer:"return=representation" });assessment=rows?.[0]; if(!assessment){ throw new Error("Test could not be created."); }if(!lessonId&&groupIds.length){ await rest("assessment_groups",{ method:"POST", body:groupIds.map(group_id=>({ assessment_id:assessment.id, group_id })), prefer:"return=minimal" }); }restore(); closeModal(); await refresh(); window.UAopenAssessment(assessment.id); toast("Test created. Add questions now."); }catch(e){ if(assessment?.id){ try{ await rest(`assessments?id=eq.${assessment.id}`,{ method:"DELETE", prefer:"return=minimal" }); }catch(_){} }toast(e.message); }finally{ restore(); } }window.UAopenAssessment=function(assessmentId){ stopAssessmentTimer();const a=assessments.find(x=>x.id===assessmentId); if(!a)return;const manager=canManageAssessment(a); const aq=questionsForAssessment(a.id); const maxMarks=assessmentMaxMarks(a.id);let body=`
Type${esc(assessmentTypeLabel(a.assessment_type))}
Status${a.is_published?"Published":"Draft"}
Questions${aq.length}
Total marks${maxMarks}
Pass${Number(a.pass_percent||0)}%
Time${a.time_limit_minutes?`${a.time_limit_minutes} min`:"No limit"}
${a.description?`
${esc(a.description)}
`:""}
${esc(assessmentTargetLabel(a))}
`;if(manager){ body+=`
`;if(aq.length){ body+=`
Questions ${aq.map((item,index)=>{ const q=questionBank.find(x=>x.id===item.question_id); return `
Q${index+1}. ${esc(q?.prompt||"Question")}
${q?.question_type==="true_false"?"True / False":"MCQ"} · ${Number(item.marks||0)} mark${Number(item.marks||0)===1?"":"s"}
`; }).join("")}
`; }else{ body+=`
Add at least one question before publishing this test.
`; }const submitted=attemptsForAssessment(a.id) .filter(x=>x.status==="submitted");if(submitted.length){ body+=`
Student results${submitted.map(attempt=>`
${esc(profileName(attempt.student_id))}
Attempt ${attempt.attempt_no} · ${formatDateTime(attempt.submitted_at)}
${Number(attempt.percentage||0).toFixed(0)}%
`).join("")}
`; }body+=`
`; }else{ const myAttempts=attemptsForAssessment(a.id,me.id) .sort((x,y)=>String(y.started_at).localeCompare(String(x.started_at))); const latest=myAttempts[0]||null;if(latest?.status==="submitted"){ body+=`
${Number(latest.percentage||0).toFixed(0)}% ${latest.passed?"Passed":"Not passed"}
${Number(latest.score||0)} / ${Number(latest.max_score||0)} marks
`; }if(aq.length){ body+=`
`; }else{ body+=`
This test has no questions yet.
`; }if(myAttempts.length){ body+=`
My attempts ${myAttempts.map(attempt=>`
Attempt ${attempt.attempt_no} · ${attempt.status==="submitted" ?`${Number(attempt.percentage||0).toFixed(0)}%` :attempt.status.replace("_"," ") }
`).join("")}
`; } }modal("Test / quiz",body);const startBtn=document.getElementById("uaStartAssessment"); if(startBtn){ startBtn.onclick=function(){ startAssessmentAttempt(a.id,this); }; } };function openQuestionBank(){ const manageable=questionBank.filter(q=> me.role==="admin"||q.created_by===me.id );modal("Question bank",`
${manageable.length?manageable.map(q=>`
${esc(q.prompt)}
${q.question_type==="true_false"?"True / False":"MCQ"}
${profileName(q.created_by)}
`).join(""):`
No questions in your Question Bank yet.
`}
`);document.getElementById("uaCreateQuestionBankItem").onclick=()=>{ openCreateQuestion(null); }; }window.UAaddQuestionToAssessment=function(assessmentId){ closeModal(); openCreateQuestion(assessmentId); };function openCreateQuestion(assessmentId=null){ modal( assessmentId?"Create & add question":"Create question", `
${assessmentId?` `:""}
` );const type=document.getElementById("uaQType"); const mcq=document.getElementById("uaMCQOptions"); const tf=document.getElementById("uaTFOptions");type.onchange=()=>{ const isTF=type.value==="true_false"; mcq.style.display=isTF?"none":"block"; tf.style.display=isTF?"block":"none"; };document.getElementById("uaSaveQuestion").onclick=function(){ saveQuestion(assessmentId,this); }; }async function saveQuestion(assessmentId,button){ const type=document.getElementById("uaQType").value; const prompt=document.getElementById("uaQPrompt").value.trim(); const explanation=document.getElementById("uaQExplanation").value.trim();if(!prompt)return toast("Enter the question.");let options=null; let correctAnswer=null;if(type==="mcq_single"){ options=[0,1,2,3].map(i=> document.getElementById(`uaQOpt${i}`).value.trim() );if(options.some(x=>!x)){ return toast("Enter all four MCQ options."); }correctAnswer=Number( document.getElementById("uaQCorrectMCQ").value ); }else{ correctAnswer= document.getElementById("uaQCorrectTF").value==="true"; }let marks=1;if(assessmentId){ marks=Number(document.getElementById("uaQMarks").value||1); if(!Number.isFinite(marks)||marks<=0){ return toast("Marks must be greater than 0."); } }const restore=setButtonBusy(button,"Saving…"); let question=null;try{ const rows=await rest("question_bank",{ method:"POST", body:{ question_type:type, prompt, options, created_by:me.id }, prefer:"return=representation" });question=rows?.[0]; if(!question){ throw new Error("Question could not be created."); }await rest("question_answer_keys",{ method:"POST", body:{ question_id:question.id, correct_answer:correctAnswer, explanation:explanation||null }, prefer:"return=minimal" });if(assessmentId){ const position= Math.max( 0, ...questionsForAssessment(assessmentId) .map(x=>Number(x.position)||0) )+1;await rest("assessment_questions",{ method:"POST", body:{ assessment_id:assessmentId, question_id:question.id, position, marks }, prefer:"return=minimal" }); }restore(); closeModal(); await refresh();if(assessmentId){ window.UAopenAssessment(assessmentId); toast("Question added to test."); }else{ openQuestionBank(); toast("Question saved to Question Bank."); } }catch(e){ if(question?.id){ try{ await rest(`question_bank?id=eq.${question.id}`,{ method:"DELETE", prefer:"return=minimal" }); }catch(_){} }toast(e.message); }finally{ restore(); } }window.UAaddExistingQuestion=function(assessmentId){ const linked=new Set( questionsForAssessment(assessmentId) .map(x=>x.question_id) );const available=questionBank.filter(q=> !linked.has(q.id) && (me.role==="admin"||q.created_by===me.id) );closeModal();modal("Add from Question Bank",` ${available.length?`
` :`
No unused Question Bank item is available.
` } `);const btn=document.getElementById("uaAttachQuestion"); if(btn){ btn.onclick=function(){ attachExistingQuestion(assessmentId,this); }; } };async function attachExistingQuestion(assessmentId,button){ const questionId=document.getElementById("uaExistingQuestion").value; const marks=Number(document.getElementById("uaExistingMarks").value||1);if(!questionId)return toast("Select a question."); if(!Number.isFinite(marks)||marks<=0){ return toast("Marks must be greater than 0."); }const position= Math.max( 0, ...questionsForAssessment(assessmentId) .map(x=>Number(x.position)||0) )+1;const restore=setButtonBusy(button,"Adding…");try{ await rest("assessment_questions",{ method:"POST", body:{ assessment_id:assessmentId, question_id:questionId, position, marks }, prefer:"return=minimal" });restore(); closeModal(); await refresh(); window.UAopenAssessment(assessmentId); toast("Question added."); }catch(e){ toast(e.message); }finally{ restore(); } }window.UAremoveAssessmentQuestion=async function( assessmentId, questionId, button ){ if(!confirm("Remove this question from the test?"))return;const restore=setButtonBusy(button,"Removing…");try{ await rest( `assessment_questions?assessment_id=eq.${assessmentId}&question_id=eq.${questionId}`, { method:"DELETE", prefer:"return=minimal" } );restore(); closeModal(); await refresh(); window.UAopenAssessment(assessmentId); toast("Question removed."); }catch(e){ toast(e.message); }finally{ restore(); } };window.UAtoggleAssessmentPublish=async function(assessmentId,button){ const a=assessments.find(x=>x.id===assessmentId); if(!a||!canManageAssessment(a))return;if(!a.is_published && !assessmentQuestionCount(a.id)){ return toast("Add at least one question before publishing."); }const restore=setButtonBusy( button, a.is_published?"Unpublishing…":"Publishing…" );try{ await rest(`assessments?id=eq.${assessmentId}`,{ method:"PATCH", body:{ is_published:!a.is_published }, prefer:"return=minimal" });restore(); closeModal(); await refresh(); window.UAopenAssessment(assessmentId); toast(a.is_published?"Test unpublished.":"Test published."); }catch(e){ toast(e.message); }finally{ restore(); } };window.UAdeleteAssessment=async function(assessmentId,button){ const a=assessments.find(x=>x.id===assessmentId); if(!a||!canManageAssessment(a))return;if( !confirm( "Delete this test and all student attempts/results?" ) ){ return; }const restore=setButtonBusy(button,"Deleting…");try{ await rest(`assessments?id=eq.${assessmentId}`,{ method:"DELETE", prefer:"return=minimal" });restore(); closeModal(); await refresh(); renderMain("tests"); toast("Test deleted."); }catch(e){ toast(e.message); }finally{ restore(); } };async function startAssessmentAttempt(assessmentId,button){ const restore=setButtonBusy(button,"Starting…");try{ const attemptInfo=await rest( "rpc/start_assessment_attempt", { method:"POST", body:{ p_assessment_id:assessmentId } } );restore(); closeModal(); await refresh(); openAssessmentAttempt(assessmentId,attemptInfo); }catch(e){ toast(e.message); }finally{ restore(); } }function openAssessmentAttempt(assessmentId,attemptInfo){ stopAssessmentTimer();const a=assessments.find(x=>x.id===assessmentId); if(!a)return;const aq=questionsForAssessment(a.id); const qRows=aq.map(item=>({ ...item, question:questionBank.find(q=>q.id===item.question_id) })).filter(x=>x.question);const attemptId=attemptInfo?.attempt_id; const deadline=attemptInfo?.deadline ?new Date(attemptInfo.deadline) :null;let body=` ${deadline?`
Time remaining
`:""}
${esc(a.title)}
Attempt ${attemptInfo?.attempt_no||1} · ${assessmentMaxMarks(a.id)} marks
${qRows.map((item,index)=>{ const q=item.question;return `
Question ${index+1} · ${Number(item.marks||0)} mark${Number(item.marks||0)===1?"":"s"}
${esc(q.prompt)}${ q.question_type==="mcq_single" ?(Array.isArray(q.options)?q.options:[]).map((option,optIndex)=>` `).join("") :` ` }
`; }).join("")}
`;modal("Attempt test",body);const submitBtn=document.getElementById("uaSubmitAssessment");submitBtn.onclick=function(){ if(confirm("Submit this test now?")){ submitAssessmentAttempt( attemptId, assessmentId, this, false ); } };if(deadline){ function updateTimer(){ const el=document.getElementById("uaAssessmentTimer");if(!el){ stopAssessmentTimer(); return; }const remaining=deadline.getTime()-Date.now();if(remaining<=0){ el.textContent="Time is over — submitting…"; stopAssessmentTimer();submitAssessmentAttempt( attemptId, assessmentId, submitBtn, true ); return; }const totalSeconds=Math.ceil(remaining/1000); const mins=Math.floor(totalSeconds/60); const secs=totalSeconds%60;el.textContent= `Time remaining: ${mins}:${String(secs).padStart(2,"0")}`; }updateTimer(); assessmentTimerId=setInterval(updateTimer,1000); } }function collectAssessmentAnswers(){ const rows=[ ...document.querySelectorAll( "#ua-scheduler .ua-question-card[data-question]" ) ];return rows .map(row=>{ const questionId=row.dataset.question; const type=row.dataset.type; const checked=row.querySelector( `input[name="ua_test_${questionId}"]:checked` );if(!checked)return null;let answer;if(type==="true_false"){ answer=checked.value==="true"; }else{ answer=Number(checked.value); }return { question_id:questionId, answer }; }) .filter(Boolean); }async function submitAssessmentAttempt( attemptId, assessmentId, button, automatic=false ){ stopAssessmentTimer();const answers=collectAssessmentAnswers(); const restore=setButtonBusy( button, automatic?"Submitting…":"Submitting…" );try{ const result=await rest( "rpc/submit_assessment_attempt", { method:"POST", body:{ p_attempt_id:attemptId, p_answers:answers } } );restore(); closeModal(); await refresh(); showAssessmentResult(assessmentId,result); }catch(e){ toast(e.message); if(automatic){ closeModal(); await refresh(); renderMain("tests"); } }finally{ restore(); } }function showAssessmentResult(assessmentId,result){ const a=assessments.find(x=>x.id===assessmentId); if(!a)return;const passed=!!result?.passed; const percentage=Number(result?.percentage||0); const score=Number(result?.score||0); const maxScore=Number(result?.max_score||0);modal("Test result",`
${percentage.toFixed(0)}% ${passed?"Passed":"Not passed"}
${score} / ${maxScore} marks
${passed ?`You reached the pass mark of ${Number(a.pass_percent||0)}%.` :`Pass mark: ${Number(a.pass_percent||0)}%.`}
`);document.getElementById("uaBackTests").onclick=()=>{ closeModal(); switchPortalTab("tests"); }; }function enrollmentState(enrollment){ if(!enrollment)return null;if(enrollment.status!=="active"){ return enrollment.status; }const now=Date.now();if( enrollment.access_starts_at && new Date(enrollment.access_starts_at).getTime()>now ){ return "scheduled"; }if( enrollment.access_expires_at && new Date(enrollment.access_expires_at).getTime()<=now ){ return "expired"; }return "active"; }function enrollmentStateLabel(enrollment){ const state=enrollmentState(enrollment);return ({ active:"Active", scheduled:"Scheduled", pending:"Pending", expired:"Expired", cancelled:"Cancelled" })[state]||"Access"; }function isEnrollmentCurrentlyActive(enrollment){ return enrollmentState(enrollment)==="active"; }function courseIndividualEnrollments(courseId){ return courseEnrollments .filter(e=>e.course_id===courseId) .sort( (a,b)=> String(b.enrolled_at||"") .localeCompare(String(a.enrolled_at||"")) ); }function studentCourseEnrollment( courseId, studentId=me?.id ){ return courseEnrollments.find( e=> e.course_id===courseId && e.student_id===studentId )||null; }function childCourseEnrollments(studentId){ return courseEnrollments.filter( e=>e.student_id===studentId ); }function linkedChildrenCourseAccess(){ const childIds=new Set( linkedChildren().map(child=>child.id) );return courseEnrollments.filter( e=>childIds.has(e.student_id) ); }function enrollmentCourseName(enrollment){ return courses.find( c=>c.id===enrollment.course_id )?.title||"Recorded Course"; }function enrollmentAccessText(enrollment){ const state=enrollmentState(enrollment);if(state==="scheduled"){ return `Starts ${formatDateTime(enrollment.access_starts_at)}`; }if(state==="expired"){ return enrollment.access_expires_at ?`Expired ${formatDateTime(enrollment.access_expires_at)}` :"Expired"; }if(state==="cancelled"){ return "Access cancelled"; }if(state==="pending"){ return "Waiting for activation"; }if(enrollment.access_expires_at){ return `Access until ${formatDateTime(enrollment.access_expires_at)}`; }return "No expiry"; }function courseGroupIds(courseId){ return courseGroups .filter(cg=>cg.course_id===courseId) .map(cg=>cg.group_id); }function courseGroupLabels(courseId){ return courseGroupIds(courseId) .map(id=>academicGroups.find(g=>g.id===id)) .filter(Boolean) .map(group=>group.name); }function sectionsForCourse(courseId){ return courseSections .filter(s=>s.course_id===courseId) .sort((a,b)=>(a.position||0)-(b.position||0)); }function lessonsForSection(sectionId){ return courseLessons .filter(l=>l.section_id===sectionId) .sort((a,b)=>(a.position||0)-(b.position||0)); }function lessonsForCourse(courseId){ const sectionIds=sectionsForCourse(courseId).map(s=>s.id); return courseLessons .filter(l=>sectionIds.includes(l.section_id)) .sort((a,b)=>(a.position||0)-(b.position||0)); }function linkedResourcesForLesson(lessonId){ const ids=lessonResources .filter(x=>x.lesson_id===lessonId) .sort((a,b)=>(a.position||0)-(b.position||0)) .map(x=>x.learning_resource_id);return ids .map(id=>learningResources.find(r=>r.id===id)) .filter(Boolean); }function myLessonProgress(lessonId){ return lessonProgress.find( p=>p.lesson_id===lessonId && p.student_id===me.id ); }function courseProgressPercent(courseId){ if(!isLearner())return null;const published=lessonsForCourse(courseId) .filter(l=>l.is_published!==false);if(!published.length)return 0;const completed=published.filter( l=>myLessonProgress(l.id)?.status==="completed" ).length;return Math.round((completed/published.length)*100); }function canManageCourse(course){ return ( me.role==="admin" || ( me.role==="teacher" && course.created_by===me.id ) ); }function courseStudentIds(courseId){ const groupIds=courseGroupIds(courseId); const ids=new Set();academicGroupMembers.forEach(gm=>{ if( groupIds.includes(gm.group_id) && gm.membership_role==="student" ){ ids.add(gm.user_id); } });courseIndividualEnrollments(courseId) .filter(isEnrollmentCurrentlyActive) .forEach(enrollment=>{ ids.add(enrollment.student_id); });return [...ids]; }function courseProgressSummary(courseId){ const studentIds=courseStudentIds(courseId); const lessons=lessonsForCourse(courseId) .filter(l=>l.is_published!==false);if(!studentIds.length||!lessons.length){ return { students:studentIds.length, completedStudents:0 }; }const completedStudents=studentIds.filter(studentId=>{ return lessons.every(lesson=>{ return lessonProgress.some( p=> p.student_id===studentId && p.lesson_id===lesson.id && p.status==="completed" ); }); }).length;return { students:studentIds.length, completedStudents }; }function videoEmbedUrl(rawUrl){ if(!rawUrl)return null;try{ const url=new URL(rawUrl); const host=url.hostname.replace(/^www\./,"").toLowerCase();if(host==="youtu.be"){ const id=url.pathname.replace(/^\/+/,"").split("/")[0]; return id?`https://www.youtube.com/embed/${encodeURIComponent(id)}`:null; }if( host==="youtube.com" || host==="m.youtube.com" ){ if(url.pathname==="/watch"){ const id=url.searchParams.get("v"); return id?`https://www.youtube.com/embed/${encodeURIComponent(id)}`:null; }const match=url.pathname.match(/^\/(?:embed|shorts)\/([^/?#]+)/); if(match?.[1]){ return `https://www.youtube.com/embed/${encodeURIComponent(match[1])}`; } }if(host==="vimeo.com"||host==="player.vimeo.com"){ const parts=url.pathname.split("/").filter(Boolean); const id=parts.reverse().find(x=>/^\d+$/.test(x)); return id?`https://player.vimeo.com/video/${encodeURIComponent(id)}`:null; } }catch(_){}return null; }function renderCourses(main){ const canCreate=me.role==="admin"||me.role==="teacher";main.innerHTML=`
Recorded Courses
Structured chapters, lessons, video, notes and resources.
${canCreate?` `:""}
${courses.length?`
${courses.map(course=>{ const groupLabels=courseGroupLabels(course.id); const lessonCount=lessonsForCourse(course.id) .filter(l=>l.is_published!==false||canManageCourse(course)) .length; const progress=courseProgressPercent(course.id); const summary=isLearner() ?null :courseProgressSummary(course.id);return `
${esc(course.title)}
${esc( [course.class_level,course.subject] .filter(Boolean) .join(" · ") )}
${lessonCount} lesson${lessonCount===1?"":"s"}
${course.description?`
${esc(course.description)}
`:""}
${groupLabels.slice(0,3).map(label=>` ${esc(label)} `).join("")} ${groupLabels.length>3?` +${groupLabels.length-3}`:""}${isLearner()&&studentCourseEnrollment(course.id)?` ${esc(enrollmentStateLabel(studentCourseEnrollment(course.id)))} `:""}${course.is_published===false?` Draft`:""}
${isLearner()&&studentCourseEnrollment(course.id)?`
${esc(enrollmentAccessText(studentCourseEnrollment(course.id)))}
`:""}${isLearner()?`
${progress}% complete
`:`
${summary.completedStudents}/${summary.students} students completed
` }
`; }).join("")}
` :`
${canCreate ?"No recorded course has been created yet." :"No course is assigned to your account yet."}
` } `;if(canCreate){ main.querySelector("#uaAddCourse").onclick=openAddCourse; } }function openAddCourse(){ const groups=academicGroups.filter(g=>g.is_active!==false);if(!groups.length){ return toast("Create or join an Academic Group first."); }modal("Create recorded course",`
You can create sections and lessons after the course is saved.
`);document.getElementById("uaCreateCourse").onclick=function(){ createCourse(this); }; }async function createCourse(button){ const title=document.getElementById("uaCourseTitle").value.trim(); const description=document.getElementById("uaCourseDescription").value.trim(); const classLevel=document.getElementById("uaCourseLevel").value.trim(); const subject=document.getElementById("uaCourseSubject").value.trim(); const published=document.getElementById("uaCoursePublished").checked; const groupIds=[ ...document.getElementById("uaCourseGroups").selectedOptions ].map(o=>o.value);if(!title){ return toast("Enter a course title."); }if(!groupIds.length){ return toast("Select at least one Academic Group."); }const restore=setButtonBusy(button,"Creating…"); let course=null;try{ const rows=await rest("courses",{ method:"POST", body:{ title, description:description||null, class_level:classLevel||null, subject:subject||null, created_by:me.id, is_published:published }, prefer:"return=representation" });course=rows?.[0]; if(!course)throw new Error("Course could not be created.");await rest("course_groups",{ method:"POST", body:groupIds.map(group_id=>({ course_id:course.id, group_id })), prefer:"return=minimal" });restore(); closeModal(); await refresh(); renderMain("courses"); toast("Course created."); }catch(e){ if(course?.id){ try{ await rest(`courses?id=eq.${course.id}`,{ method:"DELETE", prefer:"return=minimal" }); }catch(_){} }toast(e.message); }finally{ restore(); } }window.UAopenCourse=function(courseId){ const course=courses.find(c=>c.id===courseId); if(!course)return;const manager=canManageCourse(course); const sections=sectionsForCourse(courseId);let body=`
Course ${esc(course.title)}
Status ${course.is_published?"Published":"Draft"}
Class ${esc(course.class_level||"—")}
Subject ${esc(course.subject||"—")}
${course.description?`
${esc(course.description)}
`:""}
${courseGroupLabels(course.id).map(label=>` ${esc(label)} `).join("")}
${isLearner()?`
${courseProgressPercent(course.id)}% complete
`:""} `;if(manager){ body+=`
`;const individualEnrollments= courseIndividualEnrollments(course.id);body+=`
Individual course access${individualEnrollments.length ?individualEnrollments.map(enrollment=>{ const student=profiles.find( p=>p.id===enrollment.student_id );const state=enrollmentState(enrollment);return `
${esc(student?.full_name||student?.email||"Student")}
${esc(student?.login_id||student?.email||"")}
${esc(enrollmentAccessText(enrollment))} · ${esc(enrollment.enrollment_source)} ${enrollment.source_reference ?` · ${esc(enrollment.source_reference)}` :""}
${esc(enrollmentStateLabel(enrollment))} ${state==="active"||state==="scheduled"||state==="pending"?` `:""}
`; }).join("") :`
No individual enrollment yet. Academic Group access still works independently.
` }
`; }if(sections.length){ body+=sections.map(section=>{ const lessons=lessonsForSection(section.id) .filter(l=>l.is_published!==false||manager);return `
${esc(section.title)}
${lessons.length} lesson${lessons.length===1?"":"s"}
${manager?` `:""}
${lessons.length?lessons.map(lesson=>{ const progress=isLearner() ?myLessonProgress(lesson.id) :null;return `
${progress?.status==="completed"?"✅ ":""} ${esc(lesson.title)}
${lesson.video_url?"Video · ":""} ${lesson.estimated_minutes?`${lesson.estimated_minutes} min`:""} ${lesson.is_published===false?" · Draft":""}
Open
`; }).join(""):`
No lessons in this section yet.
`}
`; }).join(""); }else{ body+=`
No sections have been created yet.
`; }if(manager){ body+=`
`; }modal("Recorded course",body); };window.UAenrollCourseStudent=function(courseId){ const course=courses.find(c=>c.id===courseId); if(!course||!canManageCourse(course))return;const students=profiles.filter(isStudentProfile);if(!students.length){ return toast("No Student account is available."); }closeModal();modal("Enroll student in course",`
${esc(course.title)}
This grants access only to this recorded course. It does not add the Student to live classes, Homework or Attendance.
Leave blank to start immediately.
Leave blank for no expiry.
`);document.getElementById("uaSaveEnrollment").onclick=function(){ saveCourseEnrollment(courseId,this); }; };async function saveCourseEnrollment(courseId,button){ const studentId= document.getElementById("uaEnrollStudent").value;const source= document.getElementById("uaEnrollSource").value;const startRaw= document.getElementById("uaEnrollStart").value;const expiryRaw= document.getElementById("uaEnrollExpiry").value;const reference= document.getElementById("uaEnrollReference").value.trim();if(!studentId){ return toast("Select a Student."); }const startAt=startRaw ?new Date(startRaw).toISOString() :new Date().toISOString();const expiresAt=expiryRaw ?new Date(expiryRaw).toISOString() :null;if( expiresAt && new Date(expiresAt).getTime()<= new Date(startAt).getTime() ){ return toast("Access expiry must be after the start time."); }const restore=setButtonBusy(button,"Granting…");try{ await rest( "course_enrollments?on_conflict=course_id,student_id", { method:"POST", body:{ course_id:courseId, student_id:studentId, status:"active", enrollment_source:source, access_starts_at:startAt, access_expires_at:expiresAt, source_reference:reference||null, enrolled_by:me.id }, prefer:"resolution=merge-duplicates,return=minimal" } );restore(); closeModal(); await refresh(); window.UAopenCourse(courseId); toast("Course access granted."); }catch(e){ toast(e.message); }finally{ restore(); } }window.UAcancelCourseEnrollment=async function( enrollmentId, button ){ const enrollment=courseEnrollments.find( e=>e.id===enrollmentId );if(!enrollment)return;if( !confirm( "Cancel this Student's individual course access?" ) ){ return; }const restore=setButtonBusy(button,"Cancelling…");try{ await rest( `course_enrollments?id=eq.${enrollmentId}`, { method:"PATCH", body:{ status:"cancelled" }, prefer:"return=minimal" } );restore(); closeModal(); await refresh(); window.UAopenCourse(enrollment.course_id); toast("Course access cancelled."); }catch(e){ toast(e.message); }finally{ restore(); } };window.UAaddCourseSection=function(courseId){ const course=courses.find(c=>c.id===courseId); if(!course||!canManageCourse(course))return;closeModal();modal("Add course section",`
`);document.getElementById("uaSaveSection").onclick=function(){ saveCourseSection(courseId,this); }; };async function saveCourseSection(courseId,button){ const title=document.getElementById("uaSectionTitle").value.trim();if(!title)return toast("Enter a section title.");const position= Math.max( 0, ...sectionsForCourse(courseId).map(s=>Number(s.position)||0) )+1;const restore=setButtonBusy(button,"Adding…");try{ await rest("course_sections",{ method:"POST", body:{ course_id:courseId, title, position }, prefer:"return=minimal" });restore(); closeModal(); await refresh(); window.UAopenCourse(courseId); toast("Section added."); }catch(e){ toast(e.message); }finally{ restore(); } }window.UAaddCourseLesson=function(sectionId){ const section=courseSections.find(s=>s.id===sectionId); if(!section)return;const course=courses.find(c=>c.id===section.course_id); if(!course||!canManageCourse(course))return;const allowedGroupIds=courseGroupIds(course.id); const availableResources=learningResources.filter( r=> allowedGroupIds.includes(r.group_id) && r.is_published!==false );closeModal();modal("Add lesson",`
YouTube and Vimeo links are embedded automatically.
`);document.getElementById("uaSaveLesson").onclick=function(){ saveCourseLesson(sectionId,this); }; };async function saveCourseLesson(sectionId,button){ const section=courseSections.find(s=>s.id===sectionId); if(!section)return;const title=document.getElementById("uaLessonTitle").value.trim(); const summary=document.getElementById("uaLessonSummary").value.trim(); const contentText=document.getElementById("uaLessonText").value.trim(); const videoUrl=document.getElementById("uaLessonVideo").value.trim(); const minutesRaw=document.getElementById("uaLessonMinutes").value.trim(); const published=document.getElementById("uaLessonPublished").checked;const resourceSelect=document.getElementById("uaLessonResources"); const resourceIds=resourceSelect ?[...resourceSelect.selectedOptions] .filter(o=>!o.disabled) .map(o=>o.value) :[];if(!title)return toast("Enter a lesson title.");if(videoUrl){ try{ const parsed=new URL(videoUrl); if(!["http:","https:"].includes(parsed.protocol)){ throw new Error(); } }catch{ return toast("Enter a valid video URL."); } }const estimatedMinutes=minutesRaw?Number(minutesRaw):null;if( estimatedMinutes!==null && (!Number.isInteger(estimatedMinutes)||estimatedMinutes<1) ){ return toast("Estimated duration must be a whole number of minutes."); }const position= Math.max( 0, ...lessonsForSection(sectionId).map(l=>Number(l.position)||0) )+1;const restore=setButtonBusy(button,"Adding…"); let lesson=null;try{ const rows=await rest("course_lessons",{ method:"POST", body:{ section_id:sectionId, title, summary:summary||null, content_text:contentText||null, video_url:videoUrl||null, estimated_minutes:estimatedMinutes, position, is_published:published }, prefer:"return=representation" });lesson=rows?.[0]; if(!lesson)throw new Error("Lesson could not be created.");if(resourceIds.length){ await rest("lesson_resources",{ method:"POST", body:resourceIds.map((learning_resource_id,index)=>({ lesson_id:lesson.id, learning_resource_id, position:index+1 })), prefer:"return=minimal" }); }restore(); closeModal(); await refresh(); window.UAopenCourse(section.course_id); toast("Lesson added."); }catch(e){ if(lesson?.id){ try{ await rest(`course_lessons?id=eq.${lesson.id}`,{ method:"DELETE", prefer:"return=minimal" }); }catch(_){} }toast(e.message); }finally{ restore(); } }window.UAopenLesson=function(lessonId){ const lesson=courseLessons.find(l=>l.id===lessonId); if(!lesson)return;const section=courseSections.find(s=>s.id===lesson.section_id); const course=courses.find(c=>c.id===section?.course_id);if(!section||!course)return;const manager=canManageCourse(course); const resources=linkedResourcesForLesson(lesson.id); const embed=videoEmbedUrl(lesson.video_url); const progress=isLearner() ?myLessonProgress(lesson.id) :null;let body=`
${esc(course.title)} · ${esc(section.title)}

${esc(lesson.title)}

${lesson.summary?`
${esc(lesson.summary)}
`:""}${embed?`
`:lesson.video_url?`
Recorded video
${esc(lesson.video_url)}
`:""}${lesson.content_text?`
${esc(lesson.content_text)}
`:""}${resources.length?`
Lesson resources${resources.map(resource=>`
${esc(resource.title)}
${esc(resourceKind(resource))}
`).join("")}
`:""}${lesson.estimated_minutes?`
Estimated time: ${lesson.estimated_minutes} minutes
`:""} `;if(isLearner()){ body+=`
`; }if(manager){ body+=`
`; }modal("Course lesson",body);const completeButton=document.getElementById("uaCompleteLesson"); if(completeButton&&!completeButton.disabled){ completeButton.onclick=function(){ markLessonComplete(lesson.id,this); }; } };window.UAcreateLessonQuiz=function(lessonId){ const lesson=courseLessons.find(l=>l.id===lessonId); if(!lesson)return;closeModal(); switchPortalTab("tests");setTimeout(()=>{ openCreateAssessment(); const lessonSelect=document.getElementById("uaALesson"); const typeSelect=document.getElementById("uaAType"); const titleInput=document.getElementById("uaATitle");if(lessonSelect){ lessonSelect.value=lessonId; } if(typeSelect){ typeSelect.value="lesson_quiz"; } if(titleInput&&!titleInput.value){ titleInput.value=`${lesson.title} — Quiz`; } },50); };window.UAopenCourseVideo=function(encodedUrl){ window.open( decodeURIComponent(encodedUrl), "_blank", "noopener,noreferrer" ); };async function markLessonComplete(lessonId,button){ const restore=setButtonBusy(button,"Saving…");try{ const now=new Date().toISOString();await rest( "lesson_progress?on_conflict=lesson_id,student_id", { method:"POST", body:{ lesson_id:lessonId, student_id:me.id, status:"completed", completed_at:now, updated_at:now }, prefer:"resolution=merge-duplicates,return=minimal" } );restore(); closeModal(); await refresh();const lesson=courseLessons.find(l=>l.id===lessonId); const section=courseSections.find(s=>s.id===lesson?.section_id);if(section){ window.UAopenCourse(section.course_id); }else{ renderMain("courses"); }toast("Lesson marked complete."); }catch(e){ toast(e.message); }finally{ restore(); } }window.UAtoggleCoursePublish=async function(courseId,button){ const course=courses.find(c=>c.id===courseId); if(!course||!canManageCourse(course))return;const restore=setButtonBusy( button, course.is_published?"Unpublishing…":"Publishing…" );try{ await rest(`courses?id=eq.${courseId}`,{ method:"PATCH", body:{ is_published:!course.is_published }, prefer:"return=minimal" });restore(); closeModal(); await refresh(); window.UAopenCourse(courseId); toast(course.is_published?"Course unpublished.":"Course published."); }catch(e){ toast(e.message); }finally{ restore(); } };window.UAtoggleLessonPublish=async function(lessonId,button){ const lesson=courseLessons.find(l=>l.id===lessonId); if(!lesson)return;const section=courseSections.find(s=>s.id===lesson.section_id); const course=courses.find(c=>c.id===section?.course_id);if(!course||!canManageCourse(course))return;const restore=setButtonBusy( button, lesson.is_published?"Unpublishing…":"Publishing…" );try{ await rest(`course_lessons?id=eq.${lessonId}`,{ method:"PATCH", body:{ is_published:!lesson.is_published }, prefer:"return=minimal" });restore(); closeModal(); await refresh(); window.UAopenCourse(course.id); toast(lesson.is_published?"Lesson unpublished.":"Lesson published."); }catch(e){ toast(e.message); }finally{ restore(); } };window.UAdeleteLesson=async function(lessonId,button){ const lesson=courseLessons.find(l=>l.id===lessonId); if(!lesson)return;const section=courseSections.find(s=>s.id===lesson.section_id); const course=courses.find(c=>c.id===section?.course_id);if(!course||!canManageCourse(course))return; if(!confirm("Delete this lesson and its progress records?"))return;const restore=setButtonBusy(button,"Deleting…");try{ await rest(`course_lessons?id=eq.${lessonId}`,{ method:"DELETE", prefer:"return=minimal" });restore(); closeModal(); await refresh(); window.UAopenCourse(course.id); toast("Lesson deleted."); }catch(e){ toast(e.message); }finally{ restore(); } };window.UAdeleteCourse=async function(courseId,button){ const course=courses.find(c=>c.id===courseId); if(!course||!canManageCourse(course))return;if( !confirm( "Delete this complete course, all lessons and student progress?" ) ){ return; }const restore=setButtonBusy(button,"Deleting…");try{ await rest(`courses?id=eq.${courseId}`,{ method:"DELETE", prefer:"return=minimal" });restore(); closeModal(); await refresh(); renderMain("courses"); toast("Course deleted."); }catch(e){ toast(e.message); }finally{ restore(); } };async function resourceStorageUpload(path,file){ const s=await sessionReady(); if(!s?.access_token)throw new Error("Please login again.");const ctl=new AbortController(); const timer=setTimeout(()=>ctl.abort(),45000);try{ const r=await fetch( `${CONFIG.SUPABASE_URL}/storage/v1/object/learning-resources/${path.split("/").map(encodeURIComponent).join("/")}`, { method:"POST", signal:ctl.signal, headers:{ "apikey":CONFIG.SUPABASE_PUBLISHABLE_KEY, "Authorization":`Bearer ${s.access_token}`, "Content-Type":file.type||"application/octet-stream", "x-upsert":"false" }, body:file } );const body=await r.json().catch(()=>({}));if(!r.ok){ throw new Error( body?.message|| body?.error|| `File upload failed (${r.status})` ); }return body; }finally{ clearTimeout(timer); } }async function resourceStorageDownload(path,fileName,button){ const restore=setButtonBusy(button,"Opening…");try{ const s=await sessionReady(); if(!s?.access_token)throw new Error("Please login again.");const r=await fetch( `${CONFIG.SUPABASE_URL}/storage/v1/object/authenticated/learning-resources/${path.split("/").map(encodeURIComponent).join("/")}`, { headers:{ "apikey":CONFIG.SUPABASE_PUBLISHABLE_KEY, "Authorization":`Bearer ${s.access_token}` } } );if(!r.ok){ const body=await r.json().catch(()=>({})); throw new Error( body?.message|| body?.error|| `Could not open file (${r.status})` ); }const blob=await r.blob(); const url=URL.createObjectURL(blob); const a=document.createElement("a");a.href=url; a.download=fileName||"study-material"; document.body.appendChild(a); a.click(); a.remove();setTimeout(()=>URL.revokeObjectURL(url),1500); }catch(e){ toast(e.message); }finally{ restore(); } }function validResourceFile(file){ if(!file)return true;const allowedExt=[ "pdf","jpg","jpeg","png","docx","pptx","xlsx" ];const ext=(file.name.split(".").pop()||"").toLowerCase();if(file.size>20*1024*1024){ toast("Study material file must be 20 MB or smaller."); return false; }if(!allowedExt.includes(ext)){ toast("Allowed files: PDF, JPG, PNG, DOCX, PPTX or XLSX."); return false; }return true; }function resourceKind(resource){ const kinds=[]; if(resource.file_path)kinds.push("File"); if(resource.external_url)kinds.push("Link"); if(resource.content_text)kinds.push("Note"); return kinds.length?kinds.join(" + "):"Material"; }function canManageResource(resource){ return ( me.role==="admin" || ( me.role==="teacher" && resource.created_by===me.id ) ); }function renderStudyMaterial(main){ const canCreate=me.role==="admin"||me.role==="teacher";const materials=[...learningResources] .filter(r=>r.is_published!==false || canManageResource(r)) .sort((a,b)=>String(b.created_at).localeCompare(String(a.created_at)));main.innerHTML=`
Study Material
Notes, PDFs, images, worksheets, presentations and useful links.
${canCreate?` `:""}
${materials.length?`
${materials.map(resource=>`
${esc(resource.title)}
${esc(groupName(resource.group_id))}
${esc(resourceKind(resource))}
${resource.description?`
${esc(resource.description)}
`:""}
`).join("")}
` :`
No study material has been added yet.
` } `;if(canCreate){ main.querySelector("#uaAddResource").onclick=openAddResource; } }function openAddResource(){ const groups=academicGroups.filter(g=>g.is_active!==false);if(!groups.length){ return toast("No academic group is available."); }modal("Add study material",`
PDF, JPG, PNG, DOCX, PPTX or XLSX · maximum 20 MB
`);document.getElementById("uaCreateResource").onclick=function(){ createResource(this); }; }async function createResource(button){ const groupId=document.getElementById("uaRGroup").value; const title=document.getElementById("uaRTitle").value.trim(); const description=document.getElementById("uaRDescription").value.trim(); const contentText=document.getElementById("uaRText").value.trim(); const externalUrl=document.getElementById("uaRUrl").value.trim(); const file=document.getElementById("uaRFile").files?.[0]||null; const published=document.getElementById("uaRPublished").checked;if(!groupId||!title){ return toast("Academic group and title are required."); }if(!contentText&&!externalUrl&&!file){ return toast("Add text, an external link or a file."); }if(externalUrl){ try{ const parsed=new URL(externalUrl); if(!["http:","https:"].includes(parsed.protocol)){ throw new Error(); } }catch{ return toast("Enter a valid https:// or http:// link."); } }if(file&&!validResourceFile(file))return;const restore=setButtonBusy(button,"Saving…"); let resource=null;try{ const rows=await rest("learning_resources",{ method:"POST", body:{ group_id:groupId, title, description:description||null, content_text:contentText||null, external_url:externalUrl||null, created_by:me.id, is_published:published }, prefer:"return=representation" });resource=rows?.[0]; if(!resource){ throw new Error("Study material could not be created."); }if(file){ const path= `resources/${resource.id}/${crypto.randomUUID()}-${safeFileName(file.name)}`;await resourceStorageUpload(path,file);await rest(`learning_resources?id=eq.${resource.id}`,{ method:"PATCH", body:{ file_path:path, file_name:file.name, file_type:file.type||null, file_size:file.size }, prefer:"return=minimal" }); }restore(); closeModal(); await refresh(); renderMain("materials"); toast("Study material saved."); }catch(e){ if(resource?.id){ try{ await rest(`learning_resources?id=eq.${resource.id}`,{ method:"DELETE", prefer:"return=minimal" }); }catch(_){} }toast(e.message); }finally{ restore(); } }window.UAopenResource=function(resourceId){ const resource=learningResources.find(r=>r.id===resourceId); if(!resource)return;let body=`
Academic group${esc(groupName(resource.group_id))}
Type${esc(resourceKind(resource))}
Added by${esc(profileName(resource.created_by))}
Added${formatDateTime(resource.created_at)}
${esc(resource.title)}
${resource.description?`
${esc(resource.description)}
`:""}${resource.content_text?`
${esc(resource.content_text)}
`:""}${resource.file_path?`
File
${esc(resource.file_name||"Study material")}
`:""}${resource.external_url?`
External link
${esc(resource.external_url)}
`:""}${resource.is_published===false?`
This material is currently saved as a draft.
`:""} `;if(canManageResource(resource)){ body+=`
`; }modal("Study material",body); };window.UAdownloadResource=function(path,name,button){ resourceStorageDownload( decodeURIComponent(path), decodeURIComponent(name), button ); };window.UAopenExternalResource=function(encodedUrl){ const url=decodeURIComponent(encodedUrl); window.open(url,"_blank","noopener,noreferrer"); };window.UAdeleteResource=async function(resourceId,button){ const resource=learningResources.find(r=>r.id===resourceId); if(!resource||!canManageResource(resource))return;if(!confirm("Delete this study material?")){ return; }const restore=setButtonBusy(button,"Deleting…");try{ if(resource.file_path){ const s=await sessionReady();await fetch( `${CONFIG.SUPABASE_URL}/storage/v1/object/learning-resources/${resource.file_path.split("/").map(encodeURIComponent).join("/")}`, { method:"DELETE", headers:{ "apikey":CONFIG.SUPABASE_PUBLISHABLE_KEY, "Authorization":`Bearer ${s.access_token}` } } ); }await rest(`learning_resources?id=eq.${resourceId}`,{ method:"DELETE", prefer:"return=minimal" });restore(); closeModal(); await refresh(); renderMain("materials"); toast("Study material deleted."); }catch(e){ toast(e.message); }finally{ restore(); } };function renderAttendance(main){ if(isLearner()){ return renderStudentAttendance(main); }const today=iso(new Date()); const visible=[...classes].sort((a,b)=>{ const da=`${a.class_date} ${a.start_time||""}`; const db=`${b.class_date} ${b.start_time||""}`; return db.localeCompare(da); });main.innerHTML=`
Attendance
Mark attendance from scheduled calendar classes.
${visible.length?`
${visible.map(c=>{ const students=classStudents(c.id); const rows=attendanceForClass(c.id); const marked=new Set(rows.map(r=>r.student_id)); const done=students.length>0 && students.every(s=>marked.has(s.id));return `
${esc(c.title)}
${pretty(c.class_date)} · ${ft(c.start_time)}–${ft(c.end_time)}
${c.group_id?`
${esc(groupName(c.group_id))}
`:""}
today?"pending":"late"}"> ${done?"Marked":c.class_date>today?"Upcoming":`${marked.size}/${students.length} marked`}
`; }).join("")}
` :`
No calendar classes are available.
` } `; }function renderStudentAttendance(main){ const stats=attendanceStats(me.id);const rows=[...stats.rows].sort((a,b)=>{ const ca=classes.find(c=>c.id===a.class_id); const cb=classes.find(c=>c.id===b.class_id); return String(cb?.class_date||"").localeCompare(String(ca?.class_date||"")); });main.innerHTML=`
My attendance
Excused classes are not counted against attendance percentage.
${stats.percent===null?"—":`${stats.percent}%`}Attendance
${stats.present}Present
${stats.late}Late
${stats.absent}Absent
Attendance history
${rows.length?rows.map(r=>{ const c=classes.find(x=>x.id===r.class_id); return `
${esc(c?.title||"Class")}
${c?pretty(c.class_date):""} ${c?.start_time?` · ${ft(c.start_time)}`:""}
${esc(r.status.charAt(0).toUpperCase()+r.status.slice(1))}
${r.note?`
${esc(r.note)}
`:""}
`; }).join(""):`
Attendance has not been marked yet.
`}
`; }window.UAopenAttendance=function(classId){ const c=classes.find(x=>x.id===classId); if(!c)return;if(isLearner()){ return switchPortalTab("attendance"); }const students=classStudents(classId); if(!students.length){ return toast("No Student account is assigned to this class."); }const body=`
${esc(c.title)}
${pretty(c.class_date)} · ${ft(c.start_time)}–${ft(c.end_time)} ${c.group_id?`
${esc(groupName(c.group_id))}`:""}
${students.map(student=>{ const existing=attendanceRecord(classId,student.id); return `
${esc(student.full_name||student.email)} ${student.login_id?`
${esc(student.login_id)}
`:""}
`; }).join("")}
`;modal("Mark attendance",body);document.getElementById("uaMarkAllPresent").onclick=()=>{ document.querySelectorAll("#ua-scheduler .uaAttStatus") .forEach(select=>select.value="present"); };document.getElementById("uaSaveAttendance").onclick=function(){ saveAttendance(classId,this); }; };async function saveAttendance(classId,button){ const rows=[...document.querySelectorAll("#ua-scheduler .ua-att-row[data-student]")];const records=rows.map(row=>({ student_id:row.dataset.student, status:row.querySelector(".uaAttStatus").value, note:row.querySelector(".uaAttNote").value.trim()||null }));if(!records.length)return toast("No students are available.");const restore=setButtonBusy(button,"Saving…");try{ const count=await rest("rpc/save_class_attendance",{ method:"POST", body:{ p_class_id:classId, p_records:records } });restore(); closeModal(); await refresh(); renderMain("attendance");const saved=Number(count)||records.length; toast(`Attendance saved for ${saved} student${saved===1?"":"s"}.`); }catch(e){ toast(e.message); }finally{ restore(); } }function groupMembers(groupId,role=null){ return academicGroupMembers.filter( gm=>gm.group_id===groupId && (!role||gm.membership_role===role) ); }function renderGroups(main){ const canCreate=me.role==="admin";main.innerHTML=`
${canCreate?"Classes / Groups":"My classes"}
Continuing class relationships for multiple teachers and students.
${canCreate?``:""}
${academicGroups.length?`
${academicGroups.map(group=>{ const teachers=groupMembers(group.id,"teacher"); const students=groupMembers(group.id,"student"); return `
${esc(group.name)}
${esc(group.class_level||"")} ${group.class_level&&group.subject?" · ":""} ${esc(group.subject||"")}
${teachers.length} teacher${teachers.length===1?"":"s"} ${students.length} student${students.length===1?"":"s"} ${group.is_active===false?`Inactive`:""}
`; }).join("")}
` :`
${canCreate ?"No academic groups yet. Create the first group." :"No academic group is assigned to this account yet."}
` }`;if(canCreate){ main.querySelector("#uaAddGroup").onclick=openAddGroup; } }function memberOptions(role,selectedIds=[]){ return profiles .filter(p=>{ if(p.is_active===false)return false; if(role==="teacher")return p.role==="teacher"; return isStudentProfile(p); }) .map(p=>` `).join(""); }function openAddGroup(){ modal("Add class / group",`
You can select multiple teachers.
You can select multiple students.
`);document.getElementById("uaCreateGroup").onclick=function(){ createGroup(this); }; }async function createGroup(button){ const name=document.getElementById("uaGName").value.trim(); const level=document.getElementById("uaGLevel").value.trim(); const subject=document.getElementById("uaGSubject").value.trim();const teachers=[ ...document.getElementById("uaGTeachers").selectedOptions ].map(o=>o.value);const students=[ ...document.getElementById("uaGStudents").selectedOptions ].map(o=>o.value);if(!name)return toast("Enter a group name."); if(!teachers.length)return toast("Select at least one teacher."); if(!students.length)return toast("Select at least one student.");const restore=setButtonBusy(button,"Creating…"); let group=null;try{ const rows=await rest("academic_groups",{ method:"POST", body:{ name, class_level:level||null, subject:subject||null, created_by:me.id }, prefer:"return=representation" });group=rows?.[0]; if(!group)throw new Error("Group could not be created.");const members=[ ...teachers.map(user_id=>({ group_id:group.id, user_id, membership_role:"teacher" })), ...students.map(user_id=>({ group_id:group.id, user_id, membership_role:"student" })) ];await rest("academic_group_members",{ method:"POST", body:members, prefer:"return=minimal" });restore(); closeModal(); await refresh(); renderMain("groups"); toast("Group created."); }catch(e){ if(group?.id){ try{ await rest(`academic_groups?id=eq.${group.id}`,{ method:"DELETE", prefer:"return=minimal" }); }catch(_){} } toast(e.message); }finally{ restore(); } }window.UAopenGroup=function(groupId){ const group=academicGroups.find(g=>g.id===groupId); if(!group)return;const teacherRows=groupMembers(groupId,"teacher"); const studentRows=groupMembers(groupId,"student");const teachers=teacherRows .map(gm=>profiles.find(p=>p.id===gm.user_id)) .filter(Boolean);const students=studentRows .map(gm=>profiles.find(p=>p.id===gm.user_id)) .filter(Boolean);let body=`
Group${esc(group.name)}
Status${group.is_active===false?"Inactive":"Active"}
Class level${esc(group.class_level||"—")}
Subject${esc(group.subject||"—")}
Teachers ${teachers.length ?teachers.map(p=>`
${esc(p.full_name||p.email)} ${p.login_id?`
${esc(p.login_id)}
`:""}
`).join("") :`
No teacher assigned.
` }
Students / Parent-Student accounts ${students.length ?students.map(p=>`
${esc(p.full_name||p.email)} ${p.login_id?`
${esc(p.login_id)}
`:""}
`).join("") :`
No student assigned.
` }
`;if(me.role==="admin"){ body+=`
`; }modal("Class / group",body); };window.UAmanageGroup=function(groupId){ const group=academicGroups.find(g=>g.id===groupId); if(!group||me.role!=="admin")return;const selectedTeachers=groupMembers(groupId,"teacher").map(x=>x.user_id); const selectedStudents=groupMembers(groupId,"student").map(x=>x.user_id);closeModal();modal("Manage group",`
`);document.getElementById("uaSaveGroup").onclick=function(){ saveGroup(groupId,this); }; };async function saveGroup(groupId,button){ const name=document.getElementById("uaGEditName").value.trim(); const level=document.getElementById("uaGEditLevel").value.trim(); const subject=document.getElementById("uaGEditSubject").value.trim(); const isActive=document.getElementById("uaGEditActive").checked;const teachers=[ ...document.getElementById("uaGEditTeachers").selectedOptions ].map(o=>o.value);const students=[ ...document.getElementById("uaGEditStudents").selectedOptions ].map(o=>o.value);if(!name)return toast("Enter a group name."); if(!teachers.length)return toast("Select at least one teacher."); if(!students.length)return toast("Select at least one student.");const restore=setButtonBusy(button,"Saving…");try{ await rest(`academic_groups?id=eq.${groupId}`,{ method:"PATCH", body:{ name, class_level:level||null, subject:subject||null, is_active:isActive }, prefer:"return=minimal" });await rest(`academic_group_members?group_id=eq.${groupId}`,{ method:"DELETE", prefer:"return=minimal" });const members=[ ...teachers.map(user_id=>({ group_id:groupId, user_id, membership_role:"teacher" })), ...students.map(user_id=>({ group_id:groupId, user_id, membership_role:"student" })) ];await rest("academic_group_members",{ method:"POST", body:members, prefer:"return=minimal" });restore(); closeModal(); await refresh(); renderMain("groups"); toast("Group updated."); }catch(e){ toast(e.message); }finally{ restore(); } }function pendingForClass(classId){return proposals.find(p=>p.class_id===classId&&p.status==="pending")} function myPendingApprovals(){return approvals.filter(a=>a.approver_user_id===me.id&&a.decision==="pending")} function renderMain(tab="dashboard"){ const main=root.querySelector("#uaMain");if(!main)return;if( isParent() && !["dashboard","announcements","fees","results","family","saved","notifications"].includes(tab) ){ tab="dashboard"; activeTab="dashboard"; }if(tab==="dashboard")return renderDashboard(main); if(tab==="announcements")return renderAnnouncements(main); if(tab==="fees")return renderFees(main); if(tab==="results")return renderResultsReports(main); if(tab==="groups")return renderGroups(main); if(tab==="homework")return renderHomework(main); if(tab==="attendance")return renderAttendance(main); if(tab==="materials")return renderStudyMaterial(main); if(tab==="courses")return renderCourses(main); if(tab==="tests")return renderAssessments(main); if(tab==="saved")return renderSavedArticles(main); if(tab==="family")return renderFamily(main); if(tab==="notifications")return renderNotifications(main); if(tab==="history")return renderHistory(main); const pending=proposals.filter(p=>p.status==="pending").length,mine=myPendingApprovals().length; main.innerHTML=`
${classes.length}Classes visible to you
${pending}Pending changes
${mine}Waiting for your response
${mine?`
${mine} schedule change${mine===1?" is":"s are"} waiting for your approval. Open a yellow class.
`:""}
${me.role==="admin"?``:""}
Swipe left/right to view the full monthly calendar.
Sun
Mon
Tue
Wed
Thu
Fri
Sat
`; main.querySelector("#uaPrev").onclick=()=>{viewDate=new Date(viewDate.getFullYear(),viewDate.getMonth()-1,1);drawCalendar()}; main.querySelector("#uaNext").onclick=()=>{viewDate=new Date(viewDate.getFullYear(),viewDate.getMonth()+1,1);drawCalendar()}; main.querySelector("#uaToday").onclick=()=>{viewDate=new Date();drawCalendar()}; if(me.role==="admin")main.querySelector("#uaAdd").onclick=openAddClass; drawCalendar(); } function drawCalendar(){ const grid=root.querySelector("#uaGrid"),month=root.querySelector("#uaMonth");if(!grid||!month)return; month.textContent=viewDate.toLocaleString("en-IN",{month:"long",year:"numeric"}); const y=viewDate.getFullYear(),m=viewDate.getMonth(),first=new Date(y,m,1),start=new Date(y,m,1-first.getDay()); grid.innerHTML=""; for(let i=0;i<42;i++){ const d=new Date(start);d.setDate(start.getDate()+i);const dayIso=iso(d); const cell=document.createElement("div");cell.className="ua-day"+(d.getMonth()!==m?" ua-out":"");cell.innerHTML=`
${d.getDate()}
`; classes.filter(c=>c.class_date===dayIso).forEach(c=>{ const p=pendingForClass(c.id),ev=document.createElement("button"); ev.className="ua-event "+(p?"yellow":"green"); ev.innerHTML=`
${ft(c.start_time)}
${esc(c.title)}
${p?"Change proposed":"Confirmed"}
`; ev.onclick=()=>openClass(c.id);cell.appendChild(ev); }); proposals.filter(p=>p.status==="pending"&&p.proposed_date===dayIso&&classes.some(c=>c.id===p.class_id&&c.class_date!==p.proposed_date)).forEach(p=>{ const c=classes.find(x=>x.id===p.class_id);if(!c)return; const ev=document.createElement("button");ev.className="ua-event yellow"; ev.innerHTML=`
${ft(p.proposed_start)}
${esc(c.title)}
Proposed time
`; ev.onclick=()=>openClass(c.id);cell.appendChild(ev); }); grid.appendChild(cell); } } function openClass(id){ const c=classes.find(x=>x.id===id);if(!c)return; const p=pendingForClass(id); let body=`
Class${esc(c.title)}
Current date${pretty(c.class_date)}
Current time${ft(c.start_time)}–${ft(c.end_time)}
Subject${esc(c.subject||"—")}
${c.group_id?`
Academic group${esc(groupName(c.group_id))}
`:""}
`; if(p){ const aa=approvals.filter(a=>a.proposal_id===p.id),my=aa.find(a=>a.approver_user_id===me.id); body+=`
🟡 Proposed change
New date${pretty(p.proposed_date)}
New time${ft(p.proposed_start)}–${ft(p.proposed_end)}
Reason${esc(p.reason||"Not provided")}
${aa.map(a=>`
${a.decision==="accepted"?"✅":a.decision==="rejected"?"❌":"⏳"} ${esc(a.approver_name||ROLE[a.approver_role])}
${ROLE[a.approver_role]} · ${esc(a.decision)}
`).join("")}
`; if(my&&my.decision==="pending"){ body+=`
`; } }else{ body+=`
`; }if(!isLearner()){ body+=`
`; }modal("Class schedule",body);const attBtn=document.getElementById("uaAttendanceShortcut"); if(attBtn){ attBtn.onclick=()=>{ closeModal(); window.UAopenAttendance(id); }; } } window.UAproposal=function(id){ const c=classes.find(x=>x.id===id);if(!c)return; modal("Propose schedule change",`
Current: ${pretty(c.class_date)}, ${ft(c.start_time)}–${ft(c.end_time)}
`); document.getElementById("uaSendProposal").onclick=function(){submitProposal(id,this)}; }; async function submitProposal(classId,button){ const date=document.getElementById("uaPDate").value,start=document.getElementById("uaPStart").value,end=document.getElementById("uaPEnd").value,reason=document.getElementById("uaReason").value.trim(); if(!date||!start||!end)return toast("Choose date and time."); if(!reason)return toast("Please enter a reason for the schedule change.");const restore=setButtonBusy(button,"Saving…"); let proposalId=null;try{ proposalId=await rest("rpc/create_schedule_proposal",{ method:"POST", body:{p_class_id:classId,p_date:date,p_start:start,p_end:end,p_reason:reason} });// The actual schedule proposal is saved. Stop the button loader now. restore(); closeModal(); await refresh(); renderMain("calendar"); toast("Schedule change proposed.");// Email delivery can take longer. Do not keep the UI/button spinning for it. await invokeFunction(proposalId,"proposal_created"); }catch(e){ toast(e.message); }finally{ restore(); } } window.UArespond=async function(proposalId,decision,button){ const restore=setButtonBusy(button,decision==="accepted"?"Accepting…":"Rejecting…");try{ const status=await rest("rpc/respond_to_schedule_proposal",{ method:"POST", body:{p_proposal_id:proposalId,p_decision:decision} });// Approval/rejection is already saved. Release the UI immediately. restore(); closeModal(); await refresh(); renderMain("calendar");toast( status==="accepted" ?"All approvals complete. Schedule updated." :status==="rejected" ?"Proposal rejected. Original schedule kept." :"Response saved." );// Email notification continues separately and no longer holds the loader. await invokeFunction(proposalId,"approval_response"); }catch(e){ toast(e.message); }finally{ restore(); } }; async function openAddClass(){ const groups=academicGroups.filter(g=>g.is_active!==false); if(!groups.length){ return toast("Create an academic group first from Classes / Groups."); }modal("Add class",`
`);const groupSelect=document.getElementById("uaCGroup"); const titleInput=document.getElementById("uaCTitle"); const subjectInput=document.getElementById("uaCSubject"); const teacherSelect=document.getElementById("uaCTeacher"); const summary=document.getElementById("uaCGroupSummary");function syncSelectedGroup(){ const group=academicGroups.find(g=>g.id===groupSelect.value); if(!group)return;const teachers=groupProfiles(group.id,"teacher"); const students=groupProfiles(group.id,"student");teacherSelect.innerHTML=teachers.map(p=>` `).join("");if(!titleInput.dataset.touched){ titleInput.value=group.name||""; } if(!subjectInput.dataset.touched){ subjectInput.value=group.subject||""; }summary.innerHTML=` ${esc(group.name)}
${teachers.length} teacher${teachers.length===1?"":"s"} · ${students.length} student${students.length===1?"":"s"}
All current group members will be added to this calendar event. `; }titleInput.addEventListener("input",()=>titleInput.dataset.touched="1"); subjectInput.addEventListener("input",()=>subjectInput.dataset.touched="1"); groupSelect.addEventListener("change",()=>{ titleInput.dataset.touched=""; subjectInput.dataset.touched=""; syncSelectedGroup(); });syncSelectedGroup();document.getElementById("uaCreateClass").onclick=function(){ createClass(this); }; }async function createClass(button){ const groupId=document.getElementById("uaCGroup").value; const title=document.getElementById("uaCTitle").value.trim(); const subject=document.getElementById("uaCSubject").value.trim(); const date=document.getElementById("uaCDate").value; const start=document.getElementById("uaCStart").value; const end=document.getElementById("uaCEnd").value; const teacher=document.getElementById("uaCTeacher").value;const memberIds=[ ...new Set(groupMemberIds(groupId)) ];const teacherIds=groupMemberIds(groupId,"teacher"); const studentIds=groupMemberIds(groupId,"student");if(!groupId||!title||!date||!start||!end){ return toast("Complete the academic group, title, date and time."); } if(!teacher||!teacherIds.includes(teacher)){ return toast("Select a primary teacher from this academic group."); } if(!studentIds.length){ return toast("This academic group has no students."); }const restore=setButtonBusy(button,"Creating…");try{ const rows=await rest("classes",{ method:"POST", body:{ title, subject:subject||null, class_date:date, start_time:start, end_time:end, teacher_id:teacher, created_by:me.id, group_id:groupId }, prefer:"return=representation" });const c=rows?.[0]; if(!c)throw new Error("Class was not returned by Supabase.");await rest("class_members",{ method:"POST", body:memberIds.map(id=>({ class_id:c.id, user_id:id })), prefer:"return=minimal" });restore(); closeModal(); await refresh(); renderMain("calendar"); toast("Class created from academic group."); }catch(e){ toast(e.message); }finally{ restore(); } }function myHomeworkSubmission(homeworkId){ return homeworkSubmissions.find( s=>s.homework_id===homeworkId && s.student_id===me.id ); }function homeworkBadge(hw){ // Parent / Student: show this account's own completion state first. if(isLearner()){ const sub=myHomeworkSubmission(hw.id); if(sub?.status==="checked")return {text:"Checked",cls:"checked"}; if(sub?.status==="submitted")return {text:"Submitted",cls:"completed"}; if(sub?.status==="completed")return {text:"Completed",cls:"completed"}; }// Admin / Teacher: calculate overall status from all Parent / Student // accounts assigned to this class. Once everyone has responded, the // due-date badge must no longer show Due soon / Overdue. if(me.role==="admin"||me.role==="teacher"){ const expected=expectedHomeworkStudents(hw); const expectedIds=expected.map(s=>s.id); const subs=homeworkSubmissions.filter( s=>s.homework_id===hw.id && expectedIds.includes(s.student_id) );if(expected.length>0){ const byStudent=new Map(subs.map(s=>[s.student_id,s])); const allResponded=expected.every(student=>{ const sub=byStudent.get(student.id); return sub && ["completed","submitted","checked"].includes(sub.status); });const allChecked=expected.every(student=>{ const sub=byStudent.get(student.id); return sub?.status==="checked"; });if(allChecked)return {text:"Checked",cls:"checked"}; if(allResponded)return {text:"Completed",cls:"completed"};const responded=expected.filter(student=>{ const sub=byStudent.get(student.id); return sub && ["completed","submitted","checked"].includes(sub.status); }).length;if(responded>0){ return {text:`${responded}/${expected.length} completed`,cls:"pending"}; } } }// Only pending homework should use due-date based badges. const now=Date.now(); const due=new Date(hw.due_at).getTime(); if(dueisStudentProfile(p)&&ids.includes(p.id) ); }const ids=classMembers .filter(cm=>cm.class_id===hw.class_id) .map(cm=>cm.user_id);return profiles.filter( p=>isStudentProfile(p)&&ids.includes(p.id) ); }function renderHomework(main){ const canCreate=me.role==="admin"||me.role==="teacher"; const items=[...homeworks].sort((a,b)=>new Date(a.due_at)-new Date(b.due_at));main.innerHTML=`
Homework
${isLearner()?"Assignments for your classes":"Create and review class homework"}
${canCreate?``:""}
${items.length?`
${items.map(hw=>{ const badge=homeworkBadge(hw); const expectedStudents=expectedHomeworkStudents(hw); const expectedIds=expectedStudents.map(s=>s.id); const subs=homeworkSubmissions.filter( s=>s.homework_id===hw.id && expectedIds.includes(s.student_id) && ["completed","submitted","checked"].includes(s.status) ); const expected=expectedStudents.length; return `
${esc(hw.title)}
${esc(homeworkTargetName(hw))}
${badge.text}
${esc(hw.instructions)}
`; }).join("")}
` :`
No homework has been assigned yet.
` }`;if(canCreate){ main.querySelector("#uaAddHomework").onclick=openAddHomework; } }async function openAddHomework(){ const available=academicGroups.filter(g=>g.is_active!==false);if(!available.length){ return toast( me.role==="admin" ?"Create an academic group first from Classes / Groups." :"No academic group is assigned to your account." ); }modal("Add homework",`
PDF, JPG, PNG or DOCX · maximum 10 MB
`);const groupSelect=document.getElementById("uaHWGroup"); const summary=document.getElementById("uaHWGroupSummary");function syncHomeworkGroup(){ const group=academicGroups.find(g=>g.id===groupSelect.value); if(!group)return;const teachers=groupMemberIds(group.id,"teacher").length; const students=groupMemberIds(group.id,"student").length;summary.innerHTML=` ${esc(group.name)}
${teachers} teacher${teachers===1?"":"s"} · ${students} student${students===1?"":"s"}
Homework will be visible to this academic group. `; }groupSelect.addEventListener("change",syncHomeworkGroup); syncHomeworkGroup();document.getElementById("uaCreateHomework").onclick=function(){ createHomework(this); }; }async function createHomework(button){ const groupId=document.getElementById("uaHWGroup").value; const title=document.getElementById("uaHWTitle").value.trim(); const instructions=document.getElementById("uaHWInstructions").value.trim(); const dueValue=document.getElementById("uaHWDue").value; const file=document.getElementById("uaHWFile").files?.[0]||null;if(!groupId||!title||!instructions||!dueValue){ return toast( "Academic group, title, instructions and due date are required." ); }if(!groupMemberIds(groupId,"student").length){ return toast("This academic group has no students."); }if(file&&!validHomeworkFile(file))return;const restore=setButtonBusy(button,"Assigning…"); let hw=null;try{ const rows=await rest("homework",{ method:"POST", body:{ group_id:groupId, class_id:null, title, instructions, due_at:new Date(dueValue).toISOString(), assigned_by:me.id }, prefer:"return=representation" });hw=rows?.[0]; if(!hw)throw new Error("Homework could not be created.");if(file){ const path= `homework/${hw.id}/${crypto.randomUUID()}-${safeFileName(file.name)}`;await storageUpload(path,file);await rest(`homework?id=eq.${hw.id}`,{ method:"PATCH", body:{ attachment_path:path, attachment_name:file.name, attachment_type:file.type||null, attachment_size:file.size }, prefer:"return=minimal" }); }restore(); closeModal(); await refresh(); activeTab="homework"; renderMain("homework"); toast("Homework assigned to academic group.");await invokeHomeworkFunction( hw.id, "homework_assigned", null ); }catch(e){ if(hw?.id){ try{ await rest(`homework?id=eq.${hw.id}`,{ method:"DELETE", prefer:"return=minimal" }); }catch(_){} } toast(e.message); }finally{ restore(); } }window.UAopenHomework=function(homeworkId){ const hw=homeworks.find(h=>h.id===homeworkId); if(!hw)return;const subs=homeworkSubmissions.filter(s=>s.homework_id===hw.id); const mySub=myHomeworkSubmission(hw.id);let body=`
Class / Group${esc(homeworkTargetName(hw))}
Due${formatDateTime(hw.due_at)}
Assigned by${esc(profileName(hw.assigned_by))}
Assigned${formatDateTime(hw.created_at)}
${esc(hw.title)}
${esc(hw.instructions)}
${hw.attachment_path?`
Attachment
${esc(hw.attachment_name||"Homework file")}
`:""} `;if(isLearner()){ body+=`
Your status
${mySub?esc(mySub.status):"Not completed yet"}
${mySub?.teacher_comment?`
Teacher comment:
${esc(mySub.teacher_comment)}
`:""}
PDF, JPG, PNG or DOCX · maximum 10 MB
${mySub?.submission_path?`
Your submitted file
${esc(mySub.submission_name||"Submission")}
`:""}
`; }else{ const expected=expectedHomeworkStudents(hw); body+=`
Student status (${subs.length}/${expected.length}) ${expected.length?expected.map(student=>{ const sub=subs.find(s=>s.student_id===student.id); return `
${esc(student.full_name||student.email)}
${sub?esc(sub.status):"Pending"}
${sub?.submission_path?` `:""}
${sub?.note?`
${esc(sub.note)}
`:""} ${sub&&sub.status!=="checked"?`
`:""}
`; }).join(""):`
No Student account is assigned to this class / group.
`}
`; }modal("Homework",body); };window.UAdownloadHomework=function(path,name,button){ storageDownload(decodeURIComponent(path),decodeURIComponent(name),button); };window.UAsubmitHomework=async function(homeworkId,mode,button){ const note=document.getElementById("uaHWSubNote")?.value.trim()||""; const file=document.getElementById("uaHWSubmissionFile")?.files?.[0]||null;if(mode==="submitted"&&!file&&!myHomeworkSubmission(homeworkId)?.submission_path){ return toast("Choose a file, or use Mark completed."); } if(file&&!validHomeworkFile(file))return;const restore=setButtonBusy(button,mode==="submitted"?"Submitting…":"Saving…");try{ const existing=myHomeworkSubmission(homeworkId); let path=existing?.submission_path||null; let name=existing?.submission_name||null; let type=existing?.submission_type||null; let size=existing?.submission_size||null;if(file){ path=`submissions/${homeworkId}/${me.id}/${crypto.randomUUID()}-${safeFileName(file.name)}`; await storageUpload(path,file); name=file.name; type=file.type||null; size=file.size; }const status=file||path?"submitted":"completed";const submissionRows=await rest( "homework_submissions?on_conflict=homework_id,student_id", { method:"POST", body:{ homework_id:homeworkId, student_id:me.id, status:mode==="completed"&&!file&&!path?"completed":status, note:note||null, submission_path:path, submission_name:name, submission_type:type, submission_size:size, submitted_at:new Date().toISOString(), checked_by:null, checked_at:null }, prefer:"resolution=merge-duplicates,return=representation" } );const savedSubmission=submissionRows?.[0]||null;restore(); closeModal(); await refresh(); renderMain("homework"); toast( mode==="completed" ?"Homework marked completed." :"Homework submitted." );await invokeHomeworkFunction( homeworkId, mode==="completed" ?"homework_completed" :"homework_submitted", savedSubmission?.id||null ); }catch(e){ toast(e.message); }finally{ restore(); } };window.UAcheckHomework=async function(submissionId,button){ const restore=setButtonBusy(button,"Checking…"); const before=homeworkSubmissions.find( s=>s.id===submissionId );try{ await rest(`homework_submissions?id=eq.${submissionId}`,{ method:"PATCH", body:{ status:"checked", checked_by:me.id, checked_at:new Date().toISOString() }, prefer:"return=minimal" });restore(); closeModal(); await refresh(); renderMain("homework"); toast("Homework marked checked.");if(before?.homework_id){ await invokeHomeworkFunction( before.homework_id, "homework_checked", submissionId ); } }catch(e){ toast(e.message); }finally{ restore(); } };function renderNotifications(main){ main.innerHTML=`
Notification delivery log

You see your own delivery records; Admin can see all.

${notifications.length?notifications.map(n=>`
${new Date(n.created_at).toLocaleString("en-IN")} · ${esc(n.channel)} · ${esc(n.status)}
${esc(n.message)}
`).join(""):`

No notifications yet.

`}
`; } function renderHistory(main){ const sorted=[...proposals].sort((a,b)=>new Date(b.created_at)-new Date(a.created_at)); main.innerHTML=`
Schedule change history${sorted.length?sorted.map(p=>{const c=classes.find(x=>x.id===p.class_id);return `
${new Date(p.created_at).toLocaleString("en-IN")} · ${esc(p.status)}
${esc(c?.title||"Class")}
Proposed: ${pretty(p.proposed_date)} at ${ft(p.proposed_start)}${p.reason?` · ${esc(p.reason)}`:""}
`}).join(""):`

No schedule changes yet.

`}
`; } function modal(title,body){ const x=document.createElement("div");x.id="uaModal";x.className="ua-backdrop"; x.innerHTML=`
${esc(title)}
${body}
`; root.appendChild(x); x.querySelector("#uaClose").onclick=closeModal; x.onclick=e=>{if(e.target===x)closeModal()}; } function closeModal(){document.getElementById("uaModal")?.remove()} init(); } catch (error) { console.error("unipolaris scheduler fatal error:", error); const root = document.getElementById("ua-scheduler"); if (root) { root.innerHTML = '
' + 'Scheduler startup error

' + String(error && error.message ? error.message : error) + '
'; } } }if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", startUnipolarisScheduler); } else { startUnipolarisScheduler(); } })();