PlotDirector/PlotLine/wwwroot/js/story-intelligence-global-progress.js

199 lines
7.6 KiB
JavaScript

(() => {
const strip = document.querySelector("[data-story-intelligence-global-strip]");
if (!strip || !window.signalR) {
return;
}
const title = strip.querySelector("[data-story-intelligence-global-title]");
const detail = strip.querySelector("[data-story-intelligence-global-detail]");
const bar = strip.querySelector("[data-story-intelligence-global-bar]");
let refreshTimer = null;
let startRetryTimer = null;
const diagnosticsEnabled =
location.hostname === "localhost"
|| location.hostname === "127.0.0.1"
|| location.hostname.startsWith("dev.");
const log = (message, data) => {
if (diagnosticsEnabled && window.console?.debug) {
window.console.debug(`[StoryIntelligenceGlobalProgress] ${message}`, data || "");
}
};
let lastSnapshotTime = Date.parse(strip.dataset.storyIntelligenceUpdatedUtc || "") || 0;
const readNumber = (value) => {
const parsed = Number.parseInt(value || "", 10);
return Number.isInteger(parsed) ? parsed : null;
};
let lastSnapshotScore =
(readNumber(strip.dataset.completedScenes) || 0)
+ (readNumber(strip.dataset.failedScenes) || 0);
let lastSnapshotKey = `${strip.dataset.projectId || ""}:${strip.dataset.bookId || ""}`;
const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0));
const estimateRemaining = (elapsedMs, completed, failed, total, status) => {
if (status === "Finalising") {
return "Finalising";
}
if (!total || !completed) {
return "Calculating";
}
const remainingScenes = Math.max(0, total - completed - failed);
if (remainingScenes === 0) {
return "Finalising";
}
const remainingMs = (elapsedMs / completed) * remainingScenes;
const remainingSeconds = Math.max(1, Math.round(remainingMs / 1000));
if (remainingSeconds >= 60) {
return `${Math.max(1, Math.round(remainingSeconds / 60))} min`;
}
return `${remainingSeconds} sec`;
};
const render = (progress) => {
const books = progress?.books || progress?.Books || [];
if (!books.length) {
strip.hidden = true;
strip.dataset.connection = "complete";
return;
}
const primary = books.find((book) => book.hasActiveRuns || book.HasActiveRuns) || books[0];
const activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || books.length;
const status = progress.status || progress.Status || primary.status || primary.Status || "";
const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes)
?? readNumber(primary.totalExpectedScenes ?? primary.TotalExpectedScenes)
?? 0;
const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes)
?? readNumber(primary.completedScenes ?? primary.CompletedScenes)
?? 0;
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes)
?? readNumber(primary.failedScenes ?? primary.FailedScenes)
?? 0;
const active = readNumber(progress.activeScenes ?? progress.ActiveScenes)
?? readNumber(primary.activeScenes ?? primary.ActiveScenes)
?? 0;
const updatedRaw = progress.updatedUtc || progress.UpdatedUtc || primary.updatedUtc || primary.UpdatedUtc || "";
const updatedTime = Date.parse(updatedRaw);
const snapshotKey = `${primary.projectID ?? primary.projectId ?? ""}:${primary.bookID ?? primary.bookId ?? ""}`;
const snapshotScore = completed + failed;
if (snapshotKey !== lastSnapshotKey) {
lastSnapshotKey = snapshotKey;
lastSnapshotScore = 0;
lastSnapshotTime = 0;
}
if (snapshotScore < lastSnapshotScore) {
log("ignored backwards snapshot", { completed, failed, updatedUtc: updatedRaw, snapshotScore, lastSnapshotScore });
return;
}
if (Number.isFinite(updatedTime)) {
if (updatedTime < lastSnapshotTime || (updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore)) {
log("ignored stale snapshot", { completed, failed, updatedUtc: updatedRaw, snapshotScore, lastSnapshotScore });
return;
}
lastSnapshotTime = updatedTime;
strip.dataset.storyIntelligenceUpdatedUtc = new Date(updatedTime).toISOString();
} else if (snapshotScore < lastSnapshotScore) {
return;
}
lastSnapshotScore = Math.max(lastSnapshotScore, snapshotScore);
strip.dataset.completedScenes = String(completed);
strip.dataset.failedScenes = String(failed);
strip.dataset.projectId = String(primary.projectID ?? primary.projectId ?? "");
strip.dataset.bookId = String(primary.bookID ?? primary.bookId ?? "");
strip.hidden = false;
strip.dataset.connection = "live";
const elapsedMs = readNumber(primary.totalDurationMs ?? primary.TotalDurationMs) || 0;
const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0;
if (bar) {
bar.style.width = `${percent}%`;
}
if (detail) {
const remaining = estimateRemaining(elapsedMs, completed, failed, total, status);
const failureText = failed > 0 ? ` \u00b7 ${formatCount(failed)} failed` : "";
const activeText = active > 0 ? ` \u00b7 ${formatCount(active)} active` : "";
detail.textContent = `${formatCount(completed)} of ${formatCount(total)} scenes analysed${failureText}${activeText} \u00b7 ${remaining} remaining`;
}
if (title && status) {
if (status === "Finalising") {
title.textContent = "Finalising Story Intelligence";
} else if (status === "Failed") {
title.textContent = "Story Intelligence needs attention";
} else if (status === "Cancelled") {
title.textContent = "Story Intelligence cancelled";
} else if (status === "Completed" || status === "CompletedWithWarnings") {
title.textContent = failed > 0 ? "Story Intelligence completed with notes" : "Story Intelligence complete";
} else if (activeBookCount > 1) {
title.textContent = `${formatCount(activeBookCount)} books analysing`;
} else {
title.textContent = primary.summaryText || primary.SummaryText || title.textContent;
}
}
log("applied snapshot", { completed, failed, total, active, status, updatedUtc: updatedRaw });
};
const refresh = () => {
connection.invoke("WatchGlobalStoryIntelligenceProgress")
.then((snapshot) => {
log("authoritative refresh received", snapshot);
render(snapshot);
})
.catch(() => {
strip.dataset.connection = "offline";
log("authoritative refresh failed");
});
};
const scheduleRefresh = () => {
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(refresh, 350);
};
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/story-intelligence")
.withAutomaticReconnect()
.build();
connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh);
connection.on("StoryIntelligenceGlobalProgressChanged", (snapshot) => {
log("live snapshot received", snapshot);
render(snapshot);
});
connection.onreconnected(() => {
log("SignalR reconnected");
refresh();
});
connection.onclose(() => {
strip.dataset.connection = "offline";
log("SignalR closed; retrying start");
startWithRetry();
});
const startWithRetry = () => {
window.clearTimeout(startRetryTimer);
connection.start()
.then(() => {
strip.dataset.connection = "live";
log("SignalR connected");
refresh();
})
.catch(() => {
strip.dataset.connection = "offline";
log("SignalR initial start failed; retrying");
startRetryTimer = window.setTimeout(startWithRetry, 2000);
});
};
startWithRetry();
})();