Merge pull request #1 from StroepWafel/weightings

Weightings
This commit is contained in:
Jaxx7594
2026-01-28 16:01:51 +08:00
committed by GitHub
5 changed files with 2389 additions and 133 deletions
+1924
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -47,7 +47,7 @@
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"process": "^0.11.10", "process": "^0.11.10",
"publish-browser-extension": "^3.0.1", "publish-browser-extension": "^4.0.0",
"sass": "^1.85.1", "sass": "^1.85.1",
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
"semver": "^7.7.1", "semver": "^7.7.1",
+6 -4
View File
@@ -50,10 +50,12 @@ async function init() {
documentLoadStyle.textContent = documentLoadCSS; documentLoadStyle.textContent = documentLoadCSS;
document.head.appendChild(documentLoadStyle); document.head.appendChild(documentLoadStyle);
const icon = document.querySelector( const icons =
'link[rel*="icon"]', document.querySelectorAll<HTMLLinkElement>('link[rel*="icon"]');
)! as HTMLLinkElement;
icon.href = icon48; // Change the icon icons.forEach((link) => {
link.href = icon48;
});
try { try {
await initializeSettingsState(); await initializeSettingsState();
+437 -49
View File
@@ -48,6 +48,18 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
api.storage.weightings = {}; api.storage.weightings = {};
} }
// Clear any stuck "processing" states so they can retry
let hasStuckProcessing = false;
for (const key in api.storage.weightings) {
if (api.storage.weightings[key] === "processing") {
delete api.storage.weightings[key];
hasStuckProcessing = true;
}
}
if (hasStuckProcessing) {
api.storage.weightings = { ...api.storage.weightings };
}
api.seqta.onMount(".assessmentsWrapper", async () => { api.seqta.onMount(".assessmentsWrapper", async () => {
await waitForElm( await waitForElm(
"#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']", "#main > .assessmentsWrapper .assessments [class*='AssessmentItem__AssessmentItem___']",
@@ -125,10 +137,12 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
); );
if (!assessmentsList) return; if (!assessmentsList) return;
const gradeElements = document.querySelectorAll( // Get marks from React state to match with DOM elements
"[class*='Thermoscore__text___']", const state = await ReactFiber.find(
); "[class*='AssessmentList__items___']",
if (!gradeElements.length) return; ).getState();
const marks = state["marks"];
if (!marks || !marks.length) return;
// Parse and average grades // Parse and average grades
const letterToNumber: Record<string, number> = { const letterToNumber: Record<string, number> = {
@@ -162,19 +176,59 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
return letterToNumber[str] ?? 0; return letterToNumber[str] ?? 0;
} }
let total = 0; // Get all assessment items (excluding the average we might have added)
const assessmentItems = Array.from(
assessmentsList.querySelectorAll(`[class*='AssessmentItem__AssessmentItem___']`),
).filter(
(item) =>
!item.querySelector(`[class*='AssessmentItem__title___']`)?.textContent?.includes("Subject Average"),
);
// Match marks to assessment items and calculate weighted average
let weightedTotal = 0;
let totalWeight = 0;
let hasInaccurateWeighting = false;
let count = 0; let count = 0;
gradeElements.forEach((el) => {
const grade = parseGrade(el.textContent || ""); for (let i = 0; i < marks.length && i < assessmentItems.length; i++) {
if (grade > 0) { const mark = marks[i];
total += grade; const assessmentItem = assessmentItems[i];
count++; const gradeElement = assessmentItem.querySelector(
`[class*='Thermoscore__text___']`,
);
if (!gradeElement) continue;
const grade = parseGrade(gradeElement.textContent || "");
if (grade <= 0) continue;
const assessmentID = String(mark.id);
const weighting = api.storage.weightings[assessmentID];
// Check if weighting is unavailable or still processing
if (!weighting || weighting === "N/A" || weighting === "processing") {
hasInaccurateWeighting = true;
// Fall back to equal weighting if unavailable
weightedTotal += grade;
totalWeight += 1;
} else {
const weight = parseFloat(weighting);
if (!isNaN(weight) && weight > 0) {
weightedTotal += grade * weight;
totalWeight += weight;
} else {
// Invalid weight, use equal weighting
weightedTotal += grade;
totalWeight += 1;
hasInaccurateWeighting = true;
}
} }
}); count++;
}
if (!count) return; if (!count || totalWeight === 0) return;
const avg = total / count; const avg = weightedTotal / totalWeight;
const rounded = Math.ceil(avg / 5) * 5; const rounded = Math.ceil(avg / 5) * 5;
const numberToLetter = Object.entries(letterToNumber).reduce( const numberToLetter = Object.entries(letterToNumber).reduce(
(acc, [k, v]) => { (acc, [k, v]) => {
@@ -195,6 +249,16 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
); );
if (existing?.textContent === "Subject Average") return; if (existing?.textContent === "Subject Average") return;
// Build warning message if needed
let warningHTML = "";
if (hasInaccurateWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
⚠ Some weightings unavailable
</div>
`;
}
// Use the dynamic class names in the HTML template // Use the dynamic class names in the HTML template
const averageElement = stringToHTML(/* html */ ` const averageElement = stringToHTML(/* html */ `
<div class="${assessmentItemClass}"> <div class="${assessmentItemClass}">
@@ -202,12 +266,13 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
<div class="${metaClass}"> <div class="${metaClass}">
<div class="${simpleResultClass}"> <div class="${simpleResultClass}">
<div class="${titleClass}">Subject Average</div> <div class="${titleClass}">Subject Average</div>
${warningHTML}
</div> </div>
</div> </div>
</div> </div>
<div class="${thermoscoreClass}"> <div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%"> <div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${display}">${display}</div> <div class="${textClass}" title="${hasInaccurateWeighting ? display + ' (some weightings unavailable)' : display}">${display}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -218,19 +283,308 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
}, },
}; };
async function extractPDFText(url: string): Promise<string> { // Detect Firefox (has stricter CSP for blob URLs)
const loadingTask = pdfjs.getDocument(url); // Use userAgent instead of deprecated InstallTrigger
const pdf = await loadingTask.promise; const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1 &&
!navigator.userAgent.toLowerCase().includes('seamonkey') &&
!navigator.userAgent.toLowerCase().includes('waterfox');
let text = ""; async function fetchPDFAsArrayBuffer(url: string): Promise<ArrayBuffer> {
// Detect if URL is a blob URL
for (let i = 1; i <= pdf.numPages; i++) { const isBlobUrl = url.startsWith('blob:');
const page = await pdf.getPage(i);
const content = await page.getTextContent(); // For Firefox, ALWAYS use page context to avoid any CSP issues
text += content.items.map((item: any) => item.str).join(" ") + "\n"; // For blob URLs in any browser, use page context
if (isBlobUrl || isFirefox) {
return new Promise((resolve, reject) => {
// Inject script into page context to fetch (bypasses Firefox CSP restrictions)
const script = document.createElement('script');
const requestId = `pdf-fetch-${Date.now()}-${Math.random()}`;
// Escape URL for use in script
const escapedUrl = url.replace(/'/g, "\\'");
script.textContent = `
(function() {
fetch('${escapedUrl}')
.then(response => {
if (!response.ok) {
throw new Error('HTTP ' + response.status + ': ' + response.statusText);
}
return response.arrayBuffer();
})
.then(arrayBuffer => {
window.postMessage({
type: '${requestId}',
success: true,
data: Array.from(new Uint8Array(arrayBuffer))
}, '*');
})
.catch(error => {
window.postMessage({
type: '${requestId}',
success: false,
error: error.message || String(error)
}, '*');
});
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === requestId) {
window.removeEventListener('message', messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
if (event.data.success) {
// Convert back to ArrayBuffer
const uint8Array = new Uint8Array(event.data.data);
resolve(uint8Array.buffer);
} else {
reject(new Error(event.data.error || 'Failed to fetch PDF'));
}
}
};
window.addEventListener('message', messageHandler);
(document.head || document.documentElement).appendChild(script);
// Timeout after 30 seconds
setTimeout(() => {
window.removeEventListener('message', messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
reject(new Error('Timeout fetching PDF'));
}, 30000);
});
} else {
// Regular URL - fetch normally, but check if response URL becomes blob
try {
const response = await fetch(url, {
credentials: 'include',
redirect: 'follow',
});
// Check if response URL is a blob URL (server might redirect to blob)
if (response.url && response.url.startsWith('blob:')) {
// Re-fetch using page context
return await fetchPDFAsArrayBuffer(response.url);
}
if (!response.ok) {
throw new Error(`Failed to fetch PDF: ${response.status} ${response.statusText}`);
}
return await response.arrayBuffer();
} catch (error: any) {
// If error mentions blob or security, try using page context
if (error?.message?.includes('blob') || error?.message?.includes('Security') || error?.message?.includes('CSP')) {
// Force use page context
return await fetchPDFAsArrayBuffer(url);
}
throw error;
}
} }
}
return text; async function extractPDFText(url: string): Promise<string> {
// For Firefox, do everything in page context to avoid blob URL CSP issues
if (isFirefox) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
const requestId = `pdf-extract-${Date.now()}-${Math.random()}`;
// Escape URL for use in script (handle both single and double quotes)
const escapedUrl = url.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
script.textContent = `
(function() {
const requestId = '${requestId}';
const url = '${escapedUrl}';
// Check if pdfjs is already loaded
if (window.pdfjsLib) {
extractPDF();
} else {
// Load pdfjs in page context
const pdfjsScript = document.createElement('script');
pdfjsScript.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.mjs';
pdfjsScript.type = 'module';
pdfjsScript.onload = function() {
extractPDF();
};
pdfjsScript.onerror = function() {
window.postMessage({
type: requestId,
success: false,
error: 'Failed to load pdfjs library'
}, '*');
};
document.head.appendChild(pdfjsScript);
}
function extractPDF() {
try {
// Disable worker for Firefox to avoid blob URL CSP issues
// Set to empty string to disable worker completely
window.pdfjsLib.GlobalWorkerOptions.workerSrc = '';
// Use XMLHttpRequest instead of fetch for better blob URL handling
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'arraybuffer';
xhr.withCredentials = true;
xhr.onload = function() {
if (xhr.status !== 200) {
window.postMessage({
type: requestId,
success: false,
error: 'HTTP ' + xhr.status + ': ' + xhr.statusText
}, '*');
return;
}
try {
const arrayBuffer = xhr.response;
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
throw new Error('PDF response is empty');
}
window.pdfjsLib.getDocument({
data: arrayBuffer,
useSystemFonts: true,
verbosity: 0,
// Explicitly disable worker
useWorkerFetch: false,
isEvalSupported: false
}).promise
.then(pdf => {
const pagePromises = [];
for (let i = 1; i <= pdf.numPages; i++) {
pagePromises.push(
pdf.getPage(i).then(page => {
return page.getTextContent().then(content => {
return content.items.map(item => item.str).join(' ');
});
})
);
}
return Promise.all(pagePromises);
})
.then(pages => {
const text = pages.join('\\n');
window.postMessage({
type: requestId,
success: true,
text: text
}, '*');
})
.catch(error => {
window.postMessage({
type: requestId,
success: false,
error: 'PDF parsing error: ' + (error.message || String(error))
}, '*');
});
} catch (error) {
window.postMessage({
type: requestId,
success: false,
error: 'ArrayBuffer error: ' + (error.message || String(error))
}, '*');
}
};
xhr.onerror = function() {
window.postMessage({
type: requestId,
success: false,
error: 'Network error fetching PDF'
}, '*');
};
xhr.ontimeout = function() {
window.postMessage({
type: requestId,
success: false,
error: 'Timeout fetching PDF'
}, '*');
};
xhr.timeout = 30000;
xhr.send();
} catch (error) {
window.postMessage({
type: requestId,
success: false,
error: 'Setup error: ' + (error.message || String(error))
}, '*');
}
}
})();
`;
const messageHandler = (event: MessageEvent) => {
if (event.data?.type === requestId) {
window.removeEventListener('message', messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
if (event.data.success) {
resolve(event.data.text);
} else {
reject(new Error(event.data.error || 'Failed to extract PDF text'));
}
}
};
window.addEventListener('message', messageHandler);
(document.head || document.documentElement).appendChild(script);
// Timeout after 60 seconds (PDF parsing can take time)
setTimeout(() => {
window.removeEventListener('message', messageHandler);
if (script.parentNode) {
script.parentNode.removeChild(script);
}
reject(new Error('Timeout extracting PDF text'));
}, 60000);
});
} else {
// Chrome - use extension context
try {
const arrayBuffer = await fetchPDFAsArrayBuffer(url);
if (arrayBuffer.byteLength === 0) {
throw new Error('PDF response is empty');
}
const loadingTask = pdfjs.getDocument({
data: arrayBuffer,
useSystemFonts: true,
});
const pdf = await loadingTask.promise;
let text = "";
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
text += content.items.map((item: any) => item.str).join(" ") + "\n";
}
return text;
} catch (error) {
throw error;
}
}
} }
async function handleWeightings(mark: any, api: any) { async function handleWeightings(mark: any, api: any) {
@@ -238,45 +592,79 @@ async function handleWeightings(mark: any, api: any) {
const metaclassID = mark.metaclassID; const metaclassID = mark.metaclassID;
const userInfo = await getUserInfo(); const userInfo = await getUserInfo();
const userID = userInfo.id; const userID = userInfo.id;
if (api.storage.weightings[assessmentID] != undefined) {
// Skip if already processed (not "processing")
if (api.storage.weightings[assessmentID] != undefined && api.storage.weightings[assessmentID] !== "processing") {
return; return;
} }
// Set to processing
api.storage.weightings = { api.storage.weightings = {
...api.storage.weightings, ...api.storage.weightings,
[assessmentID]: "processing", [assessmentID]: "processing",
}; };
const filename = try {
"BetterSEQTA-" + String(Math.floor(Math.random() * 1e15)).padStart(15, "0"); const filename =
"BetterSEQTA-" + String(Math.floor(Math.random() * 1e15)).padStart(15, "0");
await fetch(`${location.origin}/seqta/student/print/assessment`, { const printResponse = await fetch(`${location.origin}/seqta/student/print/assessment`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" }, headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify({ credentials: 'include',
fileName: filename, body: JSON.stringify({
id: assessmentID, fileName: filename,
metaclass: metaclassID, id: assessmentID,
student: userID, metaclass: metaclassID,
}), student: userID,
}); }),
});
const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`; if (!printResponse.ok) {
const text = await extractPDFText(pdfUrl); throw new Error(`Failed to generate PDF: ${printResponse.status} ${printResponse.statusText}`);
}
// Use regex to find the line "Assessment weight: X" // Wait a bit for the PDF to be generated
const match = text.match(/Assessment weight:\s*(\d+\.?\d*)/i); await new Promise(resolve => setTimeout(resolve, 1000));
const weight = match ? match[1] : "N/A";
// Save it to storage const pdfUrl = `${location.origin}/seqta/student/report/get?file=${filename}`;
api.storage.weightings = {
...api.storage.weightings, // Check if URL is a blob URL (which extensions can't access)
[assessmentID]: weight, if (pdfUrl.startsWith('blob:')) {
}; throw new Error(`Cannot fetch blob URL from extension: ${pdfUrl}`);
}
let text: string;
try {
// For Firefox, extractPDFText already handles everything in page context
// For Chrome, it uses extension context
text = await extractPDFText(pdfUrl);
} catch (error: any) {
if (isFirefox && (error?.message?.includes('blob') || error?.message?.includes('Security') || error?.message?.includes('CSP'))) {
await new Promise(resolve => setTimeout(resolve, 2000));
try {
text = await extractPDFText(pdfUrl);
} catch (retryError: any) {
throw new Error(`PDF extraction failed after retry: ${retryError.message}`);
}
} else {
throw new Error(`PDF extraction failed: ${error.message}`);
}
}
console.log(`Assessment ID ${assessmentID} weight:`, weight); const match = text.match(/Assessment weight:\s*(\d+\.?\d*)/i);
const weight = match ? match[1] : "N/A";
console.log(text); api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: weight,
};
} catch (error: any) {
api.storage.weightings = {
...api.storage.weightings,
[assessmentID]: "N/A",
};
}
} }
async function parseAssessments(api: any) { async function parseAssessments(api: any) {
+21 -79
View File
@@ -104,17 +104,7 @@ export async function loadHomePage() {
const date = new Date(); const date = new Date();
const TodayFormatted = formatDate(date); const TodayFormatted = formatDate(date);
const [timetablePromise, assessmentsPromise, classesPromise, prefsPromise] = [ const [assessmentsPromise, classesPromise, prefsPromise] = [
fetch(`${location.origin}/seqta/student/load/timetable?`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from: TodayFormatted,
until: TodayFormatted,
student: 69,
}),
}).then((res) => res.json()),
GetUpcomingAssessments(), GetUpcomingAssessments(),
GetActiveClasses(), GetActiveClasses(),
@@ -126,65 +116,13 @@ export async function loadHomePage() {
}).then((res) => res.json()), }).then((res) => res.json()),
]; ];
const [timetableData, assessments, classes, prefs] = await Promise.all([ const [assessments, classes, prefs] = await Promise.all([
timetablePromise,
assessmentsPromise, assessmentsPromise,
classesPromise, classesPromise,
prefsPromise, prefsPromise,
]); ]);
const dayContainer = document.getElementById("day-container"); callHomeTimetable(TodayFormatted, true);
if (dayContainer && timetableData.payload.items.length > 0) {
const lessonArray = timetableData.payload.items.sort((a: any, b: any) =>
a.from.localeCompare(b.from),
);
const colours = await GetLessonColours();
dayContainer.innerHTML = "";
for (let i = 0; i < lessonArray.length; i++) {
const lesson = lessonArray[i];
const subjectname = `timetable.subject.colour.${lesson.code}`;
const subject = colours.find(
(element: any) => element.name === subjectname,
);
lesson.colour = subject
? `--item-colour: ${subject.value};`
: "--item-colour: #8e8e8e;";
lesson.from = lesson.from.substring(0, 5);
lesson.until = lesson.until.substring(0, 5);
if (settingsState.timeFormat === "12") {
lesson.from = convertTo12HourFormat(lesson.from);
lesson.until = convertTo12HourFormat(lesson.until);
}
lesson.attendanceTitle = CheckUnmarkedAttendance(lesson.attendance);
const div = makeLessonDiv(lesson, i + 1);
if (GetThresholdOfColor(subject?.value) > 300) {
const firstChild = div.firstChild as HTMLElement;
if (firstChild) {
firstChild.classList.add("day-inverted");
}
}
dayContainer.appendChild(div.firstChild!);
}
if (currentSelectedDate.getDate() === date.getDate()) {
for (let i = 0; i < lessonArray.length; i++) {
CheckCurrentLesson(lessonArray[i], i + 1);
}
CheckCurrentLessonAll(lessonArray);
}
} else if (dayContainer) {
dayContainer.innerHTML = `
<div class="day-empty">
<img src="${browser.runtime.getURL(LogoLight)}" />
<p>No lessons available.</p>
</div>`;
}
dayContainer?.classList.remove("loading");
const activeClass = classes.find((c: any) => c.hasOwnProperty("active")); const activeClass = classes.find((c: any) => c.hasOwnProperty("active"));
const activeSubjects = activeClass?.subjects || []; const activeSubjects = activeClass?.subjects || [];
@@ -742,7 +680,8 @@ function callHomeTimetable(date: string, change?: any) {
GetLessonColours().then((colours) => { GetLessonColours().then((colours) => {
let subjects = colours; let subjects = colours;
for (let i = 0; i < lessonArray.length; i++) { for (let i = 0; i < lessonArray.length; i++) {
let subjectname = `timetable.subject.colour.${lessonArray[i].code}`;
let subjectname = ((lessonArray[i].type == "tutorial") ? `timetable.tutor.${lessonArray[i].tutorID}` : `timetable.subject.colour.${lessonArray[i].code}`);
let subject = subjects.find( let subject = subjects.find(
(element: any) => element.name === subjectname, (element: any) => element.name === subjectname,
@@ -930,33 +869,35 @@ function makeLessonDiv(lesson: any, num: number) {
programmeID, programmeID,
metaID, metaID,
assessments, assessments,
type
} = lesson; } = lesson;
let lessonString = ` let lessonString = `
<div class="day" id="${code + num}" style="${colour}"> <div class="day" id="${code + num}" style="${colour}">
<h2>${description || "Unknown"}</h2> <h2>${(type == "class") ? description : (type == "tutorial") ? "Tutorial" : "Unknown"}</h2>
<h3>${staff || "Unknown"}</h3> <h3>${staff || "Unknown"}</h3>
<h3>${room || "Unknown"}</h3> <h3>${(type == "class") ? room : (type == "tutorial") ? "N/A" : "Unknown"}</h3>
<h4>${from || "Unknown"} - ${until || "Unknown"}</h4> <h4>${from || "Unknown"} - ${until || "Unknown"}</h4>
<h5>${attendanceTitle || "Unknown"}</h5> <h5>${attendanceTitle || "Unknown"}</h5>
`; `;
if (programmeID !== 0) { if (type == "class") {
lessonString += ` if (programmeID !== 0) {
lessonString += `
<div class="day-button clickable" style="right: 5px;" onclick="location.href='${buildAssessmentURL(programmeID, metaID)}'">${assessmentsicon}</div> <div class="day-button clickable" style="right: 5px;" onclick="location.href='${buildAssessmentURL(programmeID, metaID)}'">${assessmentsicon}</div>
<div class="day-button clickable" style="right: 35px;" onclick="location.href='../#?page=/courses/${programmeID}:${metaID}'">${coursesicon}</div> <div class="day-button clickable" style="right: 35px;" onclick="location.href='../#?page=/courses/${programmeID}:${metaID}'">${coursesicon}</div>
`; `;
} }
if (assessments && assessments.length > 0) { if (assessments && assessments.length > 0) {
const assessmentString = assessments const assessmentString = assessments
.map( .map(
(element: any) => (element: any) =>
`<p onclick="location.href = '${buildAssessmentURL(programmeID, metaID, element.id)}';">${element.title}</p>`, `<p onclick="location.href = '${buildAssessmentURL(programmeID, metaID, element.id)}';">${element.title}</p>`,
) )
.join(""); .join("");
lessonString += ` lessonString += `
<div class="fixed-tooltip assessmenttooltip"> <div class="fixed-tooltip assessmenttooltip">
<svg style="width:28px;height:28px;border-radius:0;" viewBox="0 0 24 24"> <svg style="width:28px;height:28px;border-radius:0;" viewBox="0 0 24 24">
<path fill="#ed3939" d="M16 2H4C2.9 2 2 2.9 2 4V20C2 21.11 2.9 22 4 22H16C17.11 22 18 21.11 18 20V4C18 2.9 17.11 2 16 2M16 20H4V4H6V12L8.5 9.75L11 12V4H16V20M20 15H22V17H20V15M22 7V13H20V7H22Z" /> <path fill="#ed3939" d="M16 2H4C2.9 2 2 2.9 2 4V20C2 21.11 2.9 22 4 22H16C17.11 22 18 21.11 18 20V4C18 2.9 17.11 2 16 2M16 20H4V4H6V12L8.5 9.75L11 12V4H16V20M20 15H22V17H20V15M22 7V13H20V7H22Z" />
@@ -964,6 +905,7 @@ function makeLessonDiv(lesson: any, num: number) {
<div class="tooltiptext">${assessmentString}</div> <div class="tooltiptext">${assessmentString}</div>
</div> </div>
`; `;
}
} }
lessonString += "</div>"; lessonString += "</div>";