diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index ed7140f1..b3438ea6 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -7,11 +7,11 @@ import { import { type Plugin } from "@/plugins/core/types"; import stringToHTML from "@/seqta/utils/stringToHTML"; import { waitForElm } from "@/seqta/utils/waitForElm"; -import ReactFiber from "@/seqta/utils/ReactFiber.ts"; import { clearStuck, getClassByPattern, initStorage, + injectWeightingsTab, letterToNumber, parseAssessments, processAssessments, @@ -20,6 +20,7 @@ import { interface weightingsStorage { weightings: Record; assessments: Record; + weightingOverrides: Record; } const settings = defineSettings({ @@ -37,6 +38,8 @@ class AssessmentsAveragePluginClass extends BasePlugin { const instance = new AssessmentsAveragePluginClass(); +let overrideListenerController: AbortController | null = null; + const assessmentsAveragePlugin: Plugin = { id: "assessments-average", name: "Assessment Averages", @@ -58,143 +61,149 @@ const assessmentsAveragePlugin: Plugin = { ); await parseAssessments(api); - - const sampleAssessmentItem = document.querySelector( - "[class*='AssessmentItem__AssessmentItem___']", + await renderSubjectAverage(api); + overrideListenerController?.abort(); + overrideListenerController = new AbortController(); + document.addEventListener( + "betterseqta:overrideChanged", + () => renderSubjectAverage(api), + { signal: overrideListenerController.signal }, ); - if (!sampleAssessmentItem) return; - - const assessmentItemClass = - Array.from(sampleAssessmentItem.classList).find((c) => - c.startsWith("AssessmentItem__AssessmentItem___"), - ) || ""; - - const metaContainerClass = getClassByPattern( - sampleAssessmentItem, - "AssessmentItem__metaContainer___", - ); - const metaClass = getClassByPattern( - sampleAssessmentItem, - "AssessmentItem__meta___", - ); - const simpleResultClass = getClassByPattern( - sampleAssessmentItem, - "AssessmentItem__simpleResult___", - ); - const titleClass = getClassByPattern( - sampleAssessmentItem, - "AssessmentItem__title___", - ); - - const thermoscoreElement = document.querySelector( - "[class*='Thermoscore__Thermoscore___']", - ); - if (!thermoscoreElement) return; - - const thermoscoreClass = - Array.from(thermoscoreElement.classList).find((c) => - c.startsWith("Thermoscore__Thermoscore___"), - ) || ""; - const fillClass = getClassByPattern( - thermoscoreElement, - "Thermoscore__fill___", - ); - const textClass = getClassByPattern( - thermoscoreElement, - "Thermoscore__text___", - ); - - const assessmentsList = document.querySelector( - "#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']", - ); - if (!assessmentsList) return; - - const state = await ReactFiber.find( - "[class*='AssessmentList__items___']", - ).getState(); - const marks = state["marks"]; - if (!marks || !marks.length) return; - - const assessmentItems = Array.from( - assessmentsList.querySelectorAll( - `[class*='AssessmentItem__AssessmentItem___']`, - ), - ).filter( - (item) => - !item - .querySelector(`[class*='AssessmentItem__title___']`) - ?.textContent?.includes("Subject Average"), - ); - - const { weightedTotal, totalWeight, hasInaccurateWeighting, count } = - await processAssessments(api, assessmentItems); - - if (!count || totalWeight === 0) return; - - const avg = weightedTotal / totalWeight; - const rounded = Math.ceil(avg / 5) * 5; - const numberToLetter = Object.entries(letterToNumber).reduce( - (acc, [k, v]) => { - acc[v] = k; - return acc; - }, - {} as Record, - ); - - const letterAvg = numberToLetter[rounded] ?? "N/A"; - const display = api.settings.lettergrade - ? letterAvg - : `${avg.toFixed(2)}%`; - - const existing = assessmentsList.querySelector( - `[class*='AssessmentItem__title___']`, - ); - if (existing?.textContent === "Subject Average") return; - - let warningHTML = ""; - if (hasInaccurateWeighting) { - warningHTML = /* html */ ` -
- ⚠ Some weightings unavailable -
- `; - } - - assessmentsList.insertBefore( - stringToHTML(/* html */ ` -
-
-
-
-
Subject Average
- ${warningHTML} -
-
-
-
-
-
${display}
-
-
-
- `).firstChild!, - assessmentsList.firstChild, - ); - - applySubjectColourToOverallResult(); - - const observer = new MutationObserver(() => { - applySubjectColourToOverallResult(); - }); const wrapper = document.querySelector(".assessmentsWrapper"); if (wrapper) { + const observer = new MutationObserver(() => { + applySubjectColourToOverallResult(); + }); observer.observe(wrapper, { childList: true, subtree: true }); setTimeout(() => observer.disconnect(), 10000); } }); + api.seqta.onMount("[class*='SelectedAssessment__']", () => { + injectWeightingsTab(api); + }); }, }; +let renderInFlight = false; +async function renderSubjectAverage(api: any) { + if (renderInFlight) return; + renderInFlight = true; + + try { + const assessmentsList = document.querySelector( + "#main > .assessmentsWrapper .assessments [class*='AssessmentList__items___']", + ); + if (!assessmentsList) return; + + // Remove existing subject average before re-rendering + Array.from( + assessmentsList.querySelectorAll(`[class*='AssessmentItem__title___']`), + ) + .find((el) => el.textContent === "Subject Average") + ?.closest("[class*='AssessmentItem__AssessmentItem___']") + ?.remove(); + + const sampleAssessmentItem = document.querySelector( + "[class*='AssessmentItem__AssessmentItem___']", + ); + if (!sampleAssessmentItem) return; + const assessmentItemClass = + Array.from(sampleAssessmentItem.classList).find((c) => + c.startsWith("AssessmentItem__AssessmentItem___"), + ) || ""; + const metaContainerClass = getClassByPattern( + sampleAssessmentItem, + "AssessmentItem__metaContainer___", + ); + const metaClass = getClassByPattern( + sampleAssessmentItem, + "AssessmentItem__meta___", + ); + const simpleResultClass = getClassByPattern( + sampleAssessmentItem, + "AssessmentItem__simpleResult___", + ); + const titleClass = getClassByPattern( + sampleAssessmentItem, + "AssessmentItem__title___", + ); + + const assessmentItems = Array.from( + assessmentsList.querySelectorAll( + `[class*='AssessmentItem__AssessmentItem___']`, + ), + ).filter( + (item) => + !item + .querySelector(`[class*='AssessmentItem__title___']`) + ?.textContent?.includes("Subject Average"), + ); + + const { weightedTotal, totalWeight, hasInaccurateWeighting, count } = + await processAssessments(api, assessmentItems); + if (!count || totalWeight === 0) return; + + const thermoscoreElement = document.querySelector( + "[class*='Thermoscore__Thermoscore___']", + ); + if (!thermoscoreElement) return; + const thermoscoreClass = + Array.from(thermoscoreElement.classList).find((c) => + c.startsWith("Thermoscore__Thermoscore___"), + ) || ""; + const fillClass = getClassByPattern( + thermoscoreElement, + "Thermoscore__fill___", + ); + const textClass = getClassByPattern( + thermoscoreElement, + "Thermoscore__text___", + ); + + const avg = weightedTotal / totalWeight; + const rounded = Math.ceil(avg / 5) * 5; + const numberToLetter = Object.entries(letterToNumber).reduce( + (acc, [k, v]) => { + acc[v] = k; + return acc; + }, + {} as Record, + ); + const letterAvg = numberToLetter[rounded] ?? "N/A"; + const display = api.settings.lettergrade ? letterAvg : `${avg.toFixed(2)}%`; + let warningHTML = ""; + if (hasInaccurateWeighting) { + warningHTML = /* html */ ` +
+ ⚠ Some weightings unavailable +
+ `; + } + assessmentsList.insertBefore( + stringToHTML(/* html */ ` +
+
+
+
+
Subject Average
+ ${warningHTML} +
+
+
+
+
+
${display}
+
+
+
+ `).firstChild!, + assessmentsList.firstChild, + ); + applySubjectColourToOverallResult(); + } finally { + renderInFlight = false; + } +} function applySubjectColourToOverallResult() { const selectedAssessmentItem = document.querySelector( "[class*='AssessmentItem__AssessmentItem___'][class*='selected___']", diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index d833cc98..bf7514a6 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -17,6 +17,9 @@ export async function initStorage(api: any) { if (!api.storage.assessments) { api.storage.assessments = {}; } + if (!api.storage.weightingOverrides) { + api.storage.weightingOverrides = {}; + } } export function clearStuck(api: any) { @@ -79,52 +82,84 @@ function createWeightLabel( assessmentItem: Element, weighting: string | undefined, ) { - const statsContainer = assessmentItem.querySelector( - `[class*='AssessmentItem__stats___']`, - ) as HTMLElement; + let statsContainer = assessmentItem.querySelector( + `[class*='AssessmentItem__stats___'], .betterseqta-stats-container`, + ) as HTMLElement | null; - if ( - !statsContainer || - statsContainer.querySelector(".betterseqta-weight-label") - ) - return; + if (!statsContainer) { + const statsClass = getClassByPattern(document, "AssessmentItem__stats___"); + statsContainer = document.createElement("div"); + statsContainer.className = statsClass; + statsContainer.classList.add("betterseqta-stats-container"); + const thermoscore = assessmentItem.querySelector(`[class*='Thermoscore__Thermoscore___']`); + if (thermoscore) { + thermoscore.insertAdjacentElement("afterend", statsContainer); + } else { + assessmentItem.appendChild(statsContainer); + } + } - const label = statsContainer.querySelector( - `[class*='Label__Label___']`, - ) as HTMLElement; - - if (!label) return; - - const weightLabel = label.cloneNode(true) as HTMLElement; - weightLabel.classList.add("betterseqta-weight-label"); - - const innerTextDiv = weightLabel.querySelector( - `[class*='Label__innerText___']`, + const hasNativeLabel = !!statsContainer.querySelector( + `[class*='Label__Label___']:not(.betterseqta-weight-label)`, ); + statsContainer.style.justifyContent = hasNativeLabel + ? "space-between" + : "flex-end"; + + const displayText = + weighting && weighting !== "processing" && weighting !== "N/A" + ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` + : "N/A"; + + const existingLabel = statsContainer.querySelector( + ".betterseqta-weight-label", + ) as HTMLElement | null; + + if (existingLabel) { + const textNodes = Array.from(existingLabel.childNodes).filter( + (node) => node.nodeType === Node.TEXT_NODE, + ); + if (textNodes.length) textNodes[0].textContent = displayText; + return; + } + + statsContainer.style.display = "flex"; + statsContainer.style.alignItems = "center"; + statsContainer.style.width = "100%"; + + // Try to clone an existing label from the stats container first, + // fall back to building from scratch if none exists + const existingNativeLabel = statsContainer.querySelector( + `[class*='Label__Label___']`, + ) as HTMLElement | null; + + const weightLabel = existingNativeLabel + ? (existingNativeLabel.cloneNode(true) as HTMLElement) + : (() => { + const labelClass = getClassByPattern(document, "Label__Label___"); + const innerTextClass = getClassByPattern(document, "Label__innerText___"); + const el = document.createElement("label"); + el.className = labelClass; + el.innerHTML = `
Weight
`; + return el; + })(); + + weightLabel.classList.add("betterseqta-weight-label"); + weightLabel.style.flex = "none"; + weightLabel.style.width = "fit-content"; + + const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`); if (innerTextDiv) innerTextDiv.textContent = "Weight"; const textNodes = Array.from(weightLabel.childNodes).filter( (node) => node.nodeType === Node.TEXT_NODE, ); - if (textNodes.length) { - textNodes[0].textContent = - weighting && weighting !== "processing" - ? `${Number(weighting) % 1 === 0 ? Number(weighting) : weighting}%` - : "N/A"; + textNodes[0].textContent = displayText; + } else { + weightLabel.appendChild(document.createTextNode(displayText)); } - // Stack weight under Max/native stats — absolute right:0 overlapped the max column (#414). - statsContainer.style.display = "flex"; - statsContainer.style.flexDirection = "column"; - statsContainer.style.alignItems = "flex-end"; - statsContainer.style.gap = "2px"; - statsContainer.style.justifyContent = "center"; - - weightLabel.style.position = "relative"; - weightLabel.style.inset = "unset"; - weightLabel.style.transform = "none"; - statsContainer.appendChild(weightLabel); } @@ -228,7 +263,8 @@ async function fetchPDFAsArrayBuffer(url: string): Promise { export async function extractPDFText(url: string): Promise { try { if (isFirefox) { - const { lib: pdfLibUrl, worker: pdfWorkerUrl } = getPdfjsPageContextUrls(); + const { lib: pdfLibUrl, worker: pdfWorkerUrl } = + getPdfjsPageContextUrls(); const escJsSingleQuoted = (s: string) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); const pdfLibInj = escJsSingleQuoted(pdfLibUrl); @@ -519,7 +555,11 @@ export async function parseAssessments(api: any) { "[class*='AssessmentList__items___']", ).getState(); - const marks = state["marks"]; + const marks = [ + ...(state["marks"] ?? []), + ...(state["upcoming"] ?? []), + ...(state["pending"] ?? []), + ]; if (!marks) return; await Promise.all(marks.map((mark: any) => handleWeightings(mark, api))); @@ -532,15 +572,6 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { let count = 0; for (const assessmentItem of assessmentItems) { - const gradeElement = assessmentItem.querySelector( - `[class*='Thermoscore__text___']`, - ); - - if (!gradeElement) continue; - - const grade = parseGrade(gradeElement.textContent || ""); - if (grade <= 0) continue; - const titleEl = assessmentItem.querySelector( `[class*='AssessmentItem__title___']`, ); @@ -550,12 +581,23 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { if (!title) continue; const assessmentID = api.storage.assessments?.[title]; - const weighting = assessmentID + const autoWeighting = assessmentID ? api.storage.weightings?.[assessmentID] : undefined; + const override = assessmentID + ? api.storage.weightingOverrides?.[assessmentID] + : undefined; + const weighting = override ?? autoWeighting; createWeightLabel(assessmentItem, weighting); + const gradeElement = assessmentItem.querySelector( + `[class*='Thermoscore__text___']`, + ); + if (!gradeElement) continue; + const grade = parseGrade(gradeElement.textContent || ""); + if (grade <= 0) continue; + if ( weighting === null || weighting === undefined || @@ -563,8 +605,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { weighting === "processing" ) { hasInaccurateWeighting = true; - weightedTotal += grade; - totalWeight += 1; + continue } else { const weight = parseFloat(weighting); @@ -587,3 +628,271 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { count, }; } + +function resolveTabSetClasses(): Record { + const patterns = [ + "TabSet__tabsheet___", + "TabSet__hidden___", + "TabSet__selected___", + "TabSet__disappearToLeft___", + "TabSet__disappearToRight___", + "TabSet__appearFromRight___", + "TabSet__appearFromLeft___", + ]; + + const resolved: Record = {}; + + // First pass: scan live DOM elements (fast, covers currently-applied classes) + const allClasses = Array.from( + document.querySelectorAll('[class*="TabSet__"]'), + ).flatMap((el) => Array.from(el.classList)); + + for (const pattern of patterns) { + const found = allClasses.find((c) => c.startsWith(pattern)); + if (found) resolved[pattern] = found; + } + + // Second pass: scan stylesheets for any classes not yet in the DOM + // (e.g. animation classes that haven't been applied yet) + const missing = patterns.filter((p) => !resolved[p]); + if (missing.length > 0) { + try { + for (const sheet of Array.from(document.styleSheets)) { + if (missing.every((p) => resolved[p])) break; + try { + for (const rule of Array.from(sheet.cssRules ?? [])) { + if (!(rule instanceof CSSStyleRule)) continue; + const selectorClasses = + rule.selectorText.match(/\.([\w-]+)/g) ?? []; + for (const pattern of missing) { + if (!resolved[pattern]) { + const match = selectorClasses.find((c) => + c.slice(1).startsWith(pattern), + ); + if (match) resolved[pattern] = match.slice(1); + } + } + } + } catch { + // Cross-origin stylesheet + } + } + } catch {} + } + + // Fallback: use the base pattern as-is so the function doesn't crash, + // though styles won't apply if the hash is truly unknown. + for (const pattern of patterns) { + if (!resolved[pattern]) resolved[pattern] = pattern; + } + + return resolved; +} + +function buildWeightingsTabContent(api: any, sheet: HTMLElement) { + const titleEl = document.querySelector( + "[class*='AssessmentItem__AssessmentItem___'][class*='selected___'] [class*='AssessmentItem__title___']", + ); + const title = titleEl?.textContent?.trim(); + const assessmentID = title ? api.storage.assessments?.[title] : undefined; + + const rawWeight = assessmentID + ? api.storage.weightings?.[assessmentID] + : undefined; + + const weightingUnavailable = rawWeight === "N/A"; + + const autoWeight = + rawWeight && rawWeight !== "processing" && rawWeight !== "N/A" + ? rawWeight + : undefined; + + const override = assessmentID + ? api.storage.weightingOverrides?.[assessmentID] + : undefined; + + const statusNote = !assessmentID + ? "" + : rawWeight === "processing" + ? "Weighting is still being detected." + : weightingUnavailable + ? "No weighting was found in the marksheet. Set one manually." + : "Overrides the auto-detected value."; + + sheet.innerHTML = ` + +
+

Weighting Override

+

+ Set the weighting for this assessment. + ${statusNote} +

+
+ + ${autoWeight != null ? `${autoWeight}%` : "none"} +
+
+ + +
+
+ +
+ ${!assessmentID ? `

Assessment not yet indexed — try refreshing.

` : ""} +
+ `; + + if (!assessmentID) return; + + const input = sheet.querySelector( + "#betterseqta-weight-override", + ) as HTMLInputElement; + const statusEl = sheet.querySelector( + ".betterseqta-save-status", + ) as HTMLElement; + + const save = () => { + const raw = input.value.trim(); + if (raw === "") { + const { [assessmentID]: _, ...rest } = api.storage.weightingOverrides; + api.storage.weightingOverrides = rest; + } else { + const val = parseFloat(raw); + if (isNaN(val) || val < 0) { + input.style.borderColor = "rgba(255,80,80,0.6)"; + statusEl.textContent = "Invalid. Must be 0 or greater"; + statusEl.style.color = "rgba(255,80,80,0.8)"; + return; + } + input.style.borderColor = "rgba(128,128,128,0.3)"; + api.storage.weightingOverrides = { + ...api.storage.weightingOverrides, + [assessmentID]: String(val), + }; + } + statusEl.textContent = "Saved"; + statusEl.style.color = ""; + setTimeout(() => (statusEl.textContent = ""), 2000); + document.dispatchEvent(new CustomEvent("betterseqta:overrideChanged")); + }; + + input.addEventListener("blur", save); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + input.blur(); + save(); + } + }); + input.addEventListener("input", () => { + input.style.borderColor = "rgba(128,128,128,0.3)"; + if (statusEl.textContent === "Invalid. Must be 0 or greater.") + statusEl.textContent = ""; + }); +} + +export function injectWeightingsTab(api: any) { + const tabList = document.querySelector( + '[class*="TabSet__tabs___"]', + ) as HTMLElement; + const container = document.querySelector( + '[class*="TabSet__tabContainer___"]', + ) as HTMLElement; + if (!tabList || !container) return; + if (tabList.querySelector(".betterseqta-weightings-tab")) return; + + const cls = resolveTabSetClasses(); + + const prefix = (tabList.querySelector("li") as HTMLElement).id.replace( + /-tab-\d+$/, + "", + ); + const newIndex = tabList.querySelectorAll("li").length; + + const newTab = document.createElement("li"); + newTab.id = `${prefix}-tab-${newIndex}`; + newTab.className = ""; + newTab.setAttribute("aria-selected", "false"); + newTab.setAttribute("aria-controls", `${prefix}-tabsheet-${newIndex}`); + newTab.classList.add("betterseqta-weightings-tab"); + newTab.textContent = "Weightings"; + tabList.appendChild(newTab); + + const newSheet = document.createElement("div"); + newSheet.id = `${prefix}-tabsheet-${newIndex}`; + newSheet.setAttribute("aria-labelledby", `${prefix}-tab-${newIndex}`); + newSheet.className = [ + cls["TabSet__tabsheet___"], + cls["TabSet__hidden___"], + cls["TabSet__disappearToRight___"], + ].join(" "); + container.appendChild(newSheet); + + let populated = false; + newTab.addEventListener("click", () => { + if (!populated) { + buildWeightingsTabContent(api, newSheet); + populated = true; + } + }); + + const allTabs = Array.from(tabList.querySelectorAll("li")); + const allSheets = Array.from( + container.querySelectorAll('[class*="tabsheet"]'), + ); + + allTabs.forEach((tab, i) => { + tab.addEventListener("click", () => { + const currentIndex = allTabs.findIndex((t) => + t.className.includes("TabSet__selected___"), + ); + if (i === currentIndex) return; + const goingRight = i > currentIndex; + + allTabs.forEach((t) => { + t.className = ""; + t.setAttribute("aria-selected", "false"); + }); + + allSheets[currentIndex].className = [ + cls["TabSet__tabsheet___"], + cls["TabSet__hidden___"], + goingRight + ? cls["TabSet__disappearToLeft___"] + : cls["TabSet__disappearToRight___"], + ].join(" "); + + allSheets[i].className = [ + cls["TabSet__tabsheet___"], + cls["TabSet__selected___"], + goingRight + ? cls["TabSet__appearFromRight___"] + : cls["TabSet__appearFromLeft___"], + ].join(" "); + + tab.className = cls["TabSet__selected___"]; + tab.setAttribute("aria-selected", "true"); + }); + }); +} diff --git a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts index 604b6924..6160f6e5 100644 --- a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts +++ b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts @@ -34,6 +34,10 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) { const text = stringToHTML(/* html */ `
+

3.6.5 - Assessment weighting override & fixes

+
  • Added the ability to override/add weightings to assessments (on assessment page).
  • +
  • Fixed the display of weightings that could not automatically be discovered.
  • +
  • Fixed the formatting of the weighting tag that was broken due to a SEQTA update.
  • 3.6.4 - Theme flavours and fixes, Upcoming Assements improvement

  • Added advanced colour adjustments variables for theme customisation.