mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-13 07:04:39 +00:00
Merge pull request #450 from Jaxx7594/assessmentsAverage-reindex
feat(assessmentsAverage): fingerprint-based reindex to harden against changed weightings
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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 “Don’t show again”.</li>
|
||||
<li>Fixed duplicate-result fixes.</li>
|
||||
|
||||
Reference in New Issue
Block a user