Merge pull request #450 from Jaxx7594/assessmentsAverage-reindex

feat(assessmentsAverage): fingerprint-based reindex to harden against changed weightings
This commit is contained in:
Aden Lindsay
2026-06-09 10:28:03 +09:30
committed by GitHub
3 changed files with 209 additions and 33 deletions
@@ -15,11 +15,12 @@ import {
letterToNumber,
parseAssessments,
processAssessments,
type WeightingEntry,
} from "./utils.ts";
import { injectRubricCopyButtons } from "./rubricCopy.ts";
interface weightingsStorage {
weightings: Record<string, string>;
weightings: Record<string, WeightingEntry>;
assessments: Record<string, string>;
weightingOverrides: Record<string, string>;
}
@@ -61,8 +62,8 @@ const assessmentsAveragePlugin: Plugin<typeof settings, weightingsStorage> = {
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<typeof settings, weightingsStorage> = {
() => 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<typeof settings, weightingsStorage> = {
};
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 */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3;">
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.6); opacity: 0.8; line-height: 1.3; white-space: nowrap;">
⚠ Some weightings unavailable
</div>
`;
} else if (hasRefreshingWeighting) {
warningHTML = /* html */ `
<div style="margin-top: 4px; font-size: 11px; color: rgba(255, 255, 255, 0.55); opacity: 0.8; line-height: 1.3; white-space: nowrap;" title="Some weightings are being re-checked; the average may change shortly">
↻ Refreshing weightings
</div>
`;
}
const thermoscoreTitle = hasInaccurateWeighting
? `${display} (some weightings unavailable)`
: hasRefreshingWeighting
? `${display} (re-checking weightings)`
: display;
assessmentsList.insertBefore(
stringToHTML(/* html */ `
<div class="${assessmentItemClass}">
@@ -194,7 +233,7 @@ async function renderSubjectAverage(api: any) {
</div>
<div class="${thermoscoreClass}">
<div class="${fillClass}" style="width: ${avg.toFixed(2)}%">
<div class="${textClass}" title="${hasInaccurateWeighting ? display + " (some weightings unavailable)" : display}">${display}</div>
<div class="${textClass}" title="${thermoscoreTitle}">${display}</div>
</div>
</div>
</div>
@@ -204,6 +243,10 @@ async function renderSubjectAverage(api: any) {
applySubjectColourToOverallResult();
} finally {
renderInFlight = false;
if (renderQueued) {
renderQueued = false;
void renderSubjectAverage(api);
}
}
}
function applySubjectColourToOverallResult() {
+157 -25
View File
@@ -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<string, WeightingEntry>;
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<WeightingEntry>;
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";
@@ -51,6 +51,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) {
<li>Added assessments overview and assessment weighting overrides for SEQTA Engage.</li>
<li>Added BetterSEQTA sidebar icons to SEQTA Engage.</li>
<li>Added runtime handlers for upcoming interactive theme.</li>
<li>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.</li>
<li>Fixed BetterSEQTA sidebar injection issues on some pages.</li>
<li>Tweak Theme of the Month popup making it more clear about dismissals and respecting “Dont show again”.</li>
<li>Fixed duplicate-result fixes.</li>