diff --git a/src/plugins/built-in/assessmentsAverage/index.ts b/src/plugins/built-in/assessmentsAverage/index.ts index f39918a4..6dbe2006 100644 --- a/src/plugins/built-in/assessmentsAverage/index.ts +++ b/src/plugins/built-in/assessmentsAverage/index.ts @@ -15,11 +15,12 @@ import { letterToNumber, parseAssessments, processAssessments, + type WeightingEntry, } from "./utils.ts"; import { injectRubricCopyButtons } from "./rubricCopy.ts"; interface weightingsStorage { - weightings: Record; + weightings: Record; assessments: Record; weightingOverrides: Record; } @@ -61,8 +62,8 @@ const assessmentsAveragePlugin: Plugin = { 1000, ); - await parseAssessments(api); - await renderSubjectAverage(api); + // Wire listeners first so the very first re-render triggered by a + // background handleWeightings completion can find them. overrideListenerController?.abort(); overrideListenerController = new AbortController(); document.addEventListener( @@ -70,6 +71,21 @@ const assessmentsAveragePlugin: Plugin = { () => renderSubjectAverage(api), { signal: overrideListenerController.signal }, ); + document.addEventListener( + "betterseqta:weightingsChanged", + () => renderSubjectAverage(api), + { signal: overrideListenerController.signal }, + ); + + // Render immediately with whatever is already cached. Fresh entries + // and stale-with-previous-value entries both contribute their numeric + // weights, so the subject average appears without waiting on any + // background PDF refetches. + await renderSubjectAverage(api); + + // Kick off indexing in the background. Each completion dispatches + // betterseqta:weightingsChanged, which triggers a fresh render. + void parseAssessments(api); const wrapper = document.querySelector(".assessmentsWrapper"); if (wrapper) { const observer = new MutationObserver(() => { @@ -87,8 +103,15 @@ const assessmentsAveragePlugin: Plugin = { }; let renderInFlight = false; +let renderQueued = false; async function renderSubjectAverage(api: any) { - if (renderInFlight) return; + if (renderInFlight) { + // Coalesce: remember that fresh data arrived during this render and + // re-run once the current pass finishes, so the UI catches up to the + // latest storage state instead of silently dropping the event. + renderQueued = true; + return; + } renderInFlight = true; try { @@ -141,8 +164,13 @@ async function renderSubjectAverage(api: any) { ?.textContent?.includes("Subject Average"), ); - const { weightedTotal, totalWeight, hasInaccurateWeighting, count } = - await processAssessments(api, assessmentItems); + const { + weightedTotal, + totalWeight, + hasInaccurateWeighting, + hasRefreshingWeighting, + count, + } = await processAssessments(api, assessmentItems); if (!count || totalWeight === 0) return; const thermoscoreElement = document.querySelector( @@ -176,11 +204,22 @@ async function renderSubjectAverage(api: any) { let warningHTML = ""; if (hasInaccurateWeighting) { warningHTML = /* html */ ` -
+
⚠ Some weightings unavailable
`; + } else if (hasRefreshingWeighting) { + warningHTML = /* html */ ` +
+ ↻ Refreshing weightings +
+ `; } + const thermoscoreTitle = hasInaccurateWeighting + ? `${display} (some weightings unavailable)` + : hasRefreshingWeighting + ? `${display} (re-checking weightings)` + : display; assessmentsList.insertBefore( stringToHTML(/* html */ `
@@ -194,7 +233,7 @@ async function renderSubjectAverage(api: any) {
-
${display}
+
${display}
@@ -204,6 +243,10 @@ async function renderSubjectAverage(api: any) { applySubjectColourToOverallResult(); } finally { renderInFlight = false; + if (renderQueued) { + renderQueued = false; + void renderSubjectAverage(api); + } } } function applySubjectColourToOverallResult() { diff --git a/src/plugins/built-in/assessmentsAverage/utils.ts b/src/plugins/built-in/assessmentsAverage/utils.ts index 725bcd0f..6b9519b4 100644 --- a/src/plugins/built-in/assessmentsAverage/utils.ts +++ b/src/plugins/built-in/assessmentsAverage/utils.ts @@ -14,6 +14,59 @@ import * as pdfjs from "pdfjs-dist"; ensurePdfjsWorker(); +export const WEIGHTING_SCHEMA_VERSION = 1; + +export interface WeightingEntry { + weight: string; + fingerprint: string; + pluginVersion: number; + refreshing?: boolean; +} + +export type WeightingsMap = Record; + +export function computeFingerprint(mark: any): string { + const score = + mark?.results?.percentage ?? mark?.results?.score ?? null; + return JSON.stringify([ + mark?.status ?? "", + Boolean(mark?.graded), + mark?.availability ?? "", + score, + mark?.due ?? "", + mark?.title ?? "", + ]); +} + +function migrateWeightings(api: any) { + const w = api.storage.weightings ?? {}; + let dirty = false; + const out: WeightingsMap = {}; + for (const [id, v] of Object.entries(w)) { + if (typeof v === "string") { + out[id] = { weight: v, fingerprint: "", pluginVersion: 0 }; + dirty = true; + } else if (v && typeof v === "object") { + const entry = v as Partial; + if ( + typeof entry.weight === "string" && + typeof entry.fingerprint === "string" && + typeof entry.pluginVersion === "number" + ) { + out[id] = entry as WeightingEntry; + } else { + out[id] = { + weight: String(entry.weight ?? "N/A"), + fingerprint: "", + pluginVersion: 0, + }; + dirty = true; + } + } + } + if (dirty) api.storage.weightings = out; +} + export async function initStorage(api: any) { await api.storage.loaded; @@ -26,19 +79,34 @@ export async function initStorage(api: any) { if (!api.storage.weightingOverrides) { api.storage.weightingOverrides = {}; } + + migrateWeightings(api); } export function clearStuck(api: any) { - let hasStuckProcessing = false; - for (const key in api.storage.weightings) { - if (api.storage.weightings[key] === "processing") { - delete api.storage.weightings[key]; - hasStuckProcessing = true; + const map = (api.storage.weightings ?? {}) as WeightingsMap; + let dirty = false; + const out: WeightingsMap = {}; + for (const [key, entry] of Object.entries(map)) { + if (!entry || typeof entry !== "object") { + dirty = true; + continue; } + if (entry.weight === "processing") { + // Stuck mid-fetch from a previous session: drop it so the next + // page load can re-run handleWeightings from scratch. + dirty = true; + continue; + } + if (entry.refreshing) { + const { refreshing: _ignored, ...rest } = entry; + out[key] = rest; + dirty = true; + continue; + } + out[key] = entry; } - if (hasStuckProcessing) { - api.storage.weightings = { ...api.storage.weightings }; - } + if (dirty) api.storage.weightings = out; } // Helper function to find actual class names by their base pattern @@ -137,6 +205,7 @@ function updateWeightLabelContent( weighting: string | undefined, assessmentID: string | undefined, api: any, + refreshing = false, ) { const existingInput = weightLabel.querySelector( ".betterseqta-weight-input", @@ -178,10 +247,15 @@ function updateWeightLabelContent( const span = document.createElement("span"); span.className = "betterseqta-weight-value"; - span.textContent = + const baseText = weighting && weighting !== "N/A" ? formatWeightDisplay(weighting) : "N/A"; + span.textContent = refreshing ? `${baseText} ↻` : baseText; + if (refreshing) { + span.style.opacity = "0.7"; + weightLabel.title = "Re-checking weighting…"; + } weightLabel.appendChild(span); } @@ -189,6 +263,7 @@ function createWeightLabel( assessmentItem: Element, weighting: string | undefined, api: any, + refreshing = false, ) { let statsContainer = assessmentItem.querySelector( `[class*='AssessmentItem__stats___'], .betterseqta-stats-container`, @@ -224,7 +299,13 @@ function createWeightLabel( ) as HTMLElement | null; if (existingLabel) { - updateWeightLabelContent(existingLabel, weighting, assessmentID, api); + updateWeightLabelContent( + existingLabel, + weighting, + assessmentID, + api, + refreshing, + ); return; } @@ -256,7 +337,13 @@ function createWeightLabel( const innerTextDiv = weightLabel.querySelector(`[class*='Label__innerText___']`); if (innerTextDiv) innerTextDiv.textContent = "Weight"; - updateWeightLabelContent(weightLabel, weighting, assessmentID, api); + updateWeightLabelContent( + weightLabel, + weighting, + assessmentID, + api, + refreshing, + ); statsContainer.appendChild(weightLabel); } @@ -563,16 +650,41 @@ async function handleWeightings(mark: any, api: any) { const metaclassID = mark.metaclassID; const title = mark.title; - if ( - api.storage.weightings[assessmentID] != undefined && - api.storage.weightings[assessmentID] !== "processing" - ) { - return; - } + const fingerprint = computeFingerprint(mark); + const existing = api.storage.weightings[assessmentID] as + | WeightingEntry + | undefined; + + const isFresh = + existing && + existing.weight !== "processing" && + existing.fingerprint === fingerprint && + existing.pluginVersion === WEIGHTING_SCHEMA_VERSION; + + if (isFresh) return; + + // If we have a previous usable value, keep showing it while we refetch + // by marking the entry as refreshing instead of wiping it. We claim the + // new fingerprint + version on the placeholder so a second parseAssessments + // pass (e.g. a fast re-mount of the wrapper) doesn't kick off a duplicate + // refetch for the same id while this one is still in flight. + const placeholder: WeightingEntry = + existing && existing.weight !== "processing" + ? { + ...existing, + fingerprint, + pluginVersion: WEIGHTING_SCHEMA_VERSION, + refreshing: true, + } + : { + weight: "processing", + fingerprint, + pluginVersion: WEIGHTING_SCHEMA_VERSION, + }; api.storage.weightings = { ...api.storage.weightings, - [assessmentID]: "processing", + [assessmentID]: placeholder, }; api.storage.assessments = { @@ -580,6 +692,10 @@ async function handleWeightings(mark: any, api: any) { [title.trim()]: assessmentID, }; + // Surface the refreshing indicator on the affected row immediately, + // without waiting for the PDF fetch to finish. + document.dispatchEvent(new CustomEvent("betterseqta:weightingsChanged")); + try { let pdfUrl: string; @@ -655,14 +771,24 @@ async function handleWeightings(mark: any, api: any) { api.storage.weightings = { ...api.storage.weightings, - [assessmentID]: match ? match[1] : "N/A", + [assessmentID]: { + weight: match ? match[1] : "N/A", + fingerprint, + pluginVersion: WEIGHTING_SCHEMA_VERSION, + }, }; } catch (error: any) { api.storage.weightings = { ...api.storage.weightings, - [assessmentID]: "N/A", + [assessmentID]: { + weight: "N/A", + fingerprint, + pluginVersion: WEIGHTING_SCHEMA_VERSION, + }, }; } + + document.dispatchEvent(new CustomEvent("betterseqta:weightingsChanged")); } export async function parseAssessments(api: any) { @@ -684,6 +810,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { let weightedTotal = 0; let totalWeight = 0; let hasInaccurateWeighting = false; + let hasRefreshingWeighting = false; let count = 0; for (const assessmentItem of assessmentItems) { @@ -696,15 +823,17 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { if (!title) continue; const assessmentID = api.storage.assessments?.[title]; - const autoWeighting = assessmentID - ? api.storage.weightings?.[assessmentID] + const entry = assessmentID + ? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined) : undefined; + const autoWeighting = entry?.weight; const override = assessmentID ? api.storage.weightingOverrides?.[assessmentID] : undefined; const weighting = override ?? autoWeighting; + const refreshing = !override && Boolean(entry?.refreshing); - createWeightLabel(assessmentItem, weighting, api); + createWeightLabel(assessmentItem, weighting, api, refreshing); const gradeElement = assessmentItem.querySelector( `[class*='Thermoscore__text___']`, @@ -727,6 +856,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { if (!isNaN(weight) && weight >= 0) { weightedTotal += grade * weight; totalWeight += weight; + if (refreshing) hasRefreshingWeighting = true; } else { weightedTotal += grade; totalWeight += 1; @@ -740,6 +870,7 @@ export async function processAssessments(api: any, assessmentItems: Element[]) { weightedTotal, totalWeight, hasInaccurateWeighting, + hasRefreshingWeighting, count, }; } @@ -811,9 +942,10 @@ function buildWeightingsTabContent(api: any, sheet: HTMLElement) { const title = titleEl?.textContent?.trim(); const assessmentID = title ? api.storage.assessments?.[title] : undefined; - const rawWeight = assessmentID - ? api.storage.weightings?.[assessmentID] + const entry = assessmentID + ? (api.storage.weightings?.[assessmentID] as WeightingEntry | undefined) : undefined; + const rawWeight = entry?.weight; const weightingUnavailable = rawWeight === "N/A"; diff --git a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts index 1201a8a0..780236e8 100644 --- a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts +++ b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts @@ -51,6 +51,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
  • Added assessments overview and assessment weighting overrides for SEQTA Engage.
  • Added BetterSEQTA sidebar icons to SEQTA Engage.
  • Added runtime handlers for upcoming interactive theme.
  • +
  • Added an automatic reindex of assessments if any of a series of tracked values change (title, release state, etc). Helps keep weightings up to date.
  • Fixed BetterSEQTA sidebar injection issues on some pages.
  • Tweak Theme of the Month popup making it more clear about dismissals and respecting “Don’t show again”.
  • Fixed duplicate-result fixes.