feat(assessmentsAverage): fingerprint-based reindex with non-blocking refresh

- Add WEIGHTING_SCHEMA_VERSION constant; bump to force a global lazy reindex
 - Migrate legacy Record<id, string> storage to { weight, fingerprint, pluginVersion }
 - Fingerprint per-assessment on status, graded, availability, score, due, title (sourced from the React fiber)
 - Refetch weighting only when fingerprint or schema version mismatches
 - Preserve previous weight as refreshing placeholder during background refetch
 - Render subject average immediately from cache; run parseAssessments off the critical path
 - Coalesce concurrent renderSubjectAverage calls instead of dropping them
 - Dispatch betterseqta:weightingsChanged on refetch start and completion
 - Show row-level refresh indicator and "Refreshing weightings" notice while refreshing
 - Leave weightingOverrides untouched by all reindex paths
This commit is contained in:
Jaxx7594
2026-06-07 23:14:15 +08:00
parent 6ab1f8a6a4
commit 8b470d6817
2 changed files with 208 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() {