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

394 lines
16 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 books = new Map();
const runs = new Map();
const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0));
const keyFor = (projectId, bookId) => `${projectId || ""}:${bookId || ""}`;
const field = (state, camel, pascal) => state?.[camel] ?? state?.[pascal] ?? null;
const isActiveStatus = (status) => status === "Pending" || status === "Running";
const isFailedStatus = (status) => status === "Failed" || status === "Cancelled";
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 seedBook = (book) => {
const projectId = readNumber(field(book, "projectID", "ProjectID") ?? field(book, "projectId", "ProjectId"));
const bookId = readNumber(field(book, "bookID", "BookID") ?? field(book, "bookId", "BookId"));
const key = keyFor(projectId, bookId);
books.set(key, {
projectId,
bookId,
bookTitle: field(book, "bookTitle", "BookTitle") || "Book",
totalExpectedScenes: readNumber(field(book, "totalExpectedScenes", "TotalExpectedScenes")) || 0,
completedScenes: readNumber(field(book, "completedScenes", "CompletedScenes")) || 0,
failedScenes: readNumber(field(book, "failedScenes", "FailedScenes")) || 0,
activeScenes: readNumber(field(book, "activeScenes", "ActiveScenes")) || 0,
activeBookCount: readNumber(field(book, "activeBookCount", "ActiveBookCount")) || 1,
status: field(book, "status", "Status") || "Pending",
totalDurationMs: readNumber(field(book, "totalDurationMs", "TotalDurationMs")) || 0,
updatedUtc: field(book, "updatedUtc", "UpdatedUtc") || ""
});
return books.get(key);
};
const seedBooksFromDom = () => {
strip.querySelectorAll("[data-story-intelligence-global-book]").forEach((node) => {
seedBook({
projectId: node.dataset.projectId,
bookId: node.dataset.bookId,
bookTitle: node.dataset.bookTitle,
status: node.dataset.status,
totalExpectedScenes: node.dataset.totalExpectedScenes,
completedScenes: node.dataset.completedScenes,
failedScenes: node.dataset.failedScenes,
activeScenes: node.dataset.activeScenes,
totalDurationMs: node.dataset.totalDurationMs,
updatedUtc: node.dataset.updatedUtc
});
});
};
const seedRunsFromDom = () => {
strip.querySelectorAll("[data-story-intelligence-global-run]").forEach((node) => {
const runId = readNumber(node.dataset.runId);
if (!runId) {
return;
}
runs.set(String(runId), {
runId,
projectId: readNumber(node.dataset.projectId),
bookId: readNumber(node.dataset.bookId),
bookTitle: node.dataset.bookTitle || "Book",
status: node.dataset.status || "Pending",
total: readNumber(node.dataset.totalDetectedScenes) || 0,
completed: readNumber(node.dataset.completedScenes) || 0,
failed: readNumber(node.dataset.failedScenes) || 0,
durationMs: readNumber(node.dataset.totalDurationMs) || 0,
updatedUtc: node.dataset.updatedUtc || ""
});
});
};
const seedRunsFromSnapshot = (progress) => {
(progress?.books || progress?.Books || []).forEach((book) => {
(book.runs || book.Runs || []).forEach((run) => {
const runId = readNumber(field(run, "runID", "RunID") ?? field(run, "runId", "RunId"));
if (!runId) {
return;
}
runs.set(String(runId), {
runId,
projectId: readNumber(field(run, "projectID", "ProjectID") ?? field(run, "projectId", "ProjectId")),
bookId: readNumber(field(run, "bookID", "BookID") ?? field(run, "bookId", "BookId")),
bookTitle: field(run, "bookTitle", "BookTitle") || book.bookTitle || book.BookTitle || "Book",
status: field(run, "status", "Status") || "Pending",
total: readNumber(field(run, "totalDetectedScenes", "TotalDetectedScenes")) || 0,
completed: readNumber(field(run, "completedScenes", "CompletedScenes")) || 0,
failed: readNumber(field(run, "failedScenes", "FailedScenes")) || 0,
durationMs: readNumber(field(run, "totalDurationMs", "TotalDurationMs")) || 0,
updatedUtc: field(run, "updatedUtc", "UpdatedUtc") || ""
});
});
});
};
const runSummaryForBook = (book) => {
const matchingRuns = [...runs.values()].filter((run) => keyFor(run.projectId, run.bookId) === keyFor(book.projectId, book.bookId));
if (!matchingRuns.length) {
return {
completed: book.completedScenes,
failed: book.failedScenes,
active: book.activeScenes,
durationMs: book.totalDurationMs,
status: book.status,
updatedUtc: book.updatedUtc
};
}
const completed = matchingRuns.reduce((sum, run) => sum + (run.completed || 0), 0);
const failed = matchingRuns.reduce((sum, run) => sum + (run.failed || 0), 0);
const queuedTotal = matchingRuns.reduce((sum, run) => sum + (run.total || 0), 0);
const active = matchingRuns
.filter((run) => isActiveStatus(run.status))
.reduce((sum, run) => sum + Math.max(0, (run.total || 0) - (run.completed || 0) - (run.failed || 0)), 0);
const status = matchingRuns.some((run) => isActiveStatus(run.status))
? "Running"
: matchingRuns.some((run) => isFailedStatus(run.status))
? "CompletedWithWarnings"
: book.status;
return {
completed: Math.max(book.completedScenes, completed),
failed: Math.max(book.failedScenes, failed),
active: active || book.activeScenes || Math.max(0, queuedTotal - completed - failed),
durationMs: matchingRuns.reduce((sum, run) => sum + (run.durationMs || 0), 0) || book.totalDurationMs,
status,
updatedUtc: matchingRuns
.map((run) => run.updatedUtc)
.filter(Boolean)
.sort((left, right) => Date.parse(right) - Date.parse(left))[0] || book.updatedUtc
};
};
const render = (progress) => {
const snapshotBooks = progress?.books || progress?.Books || [];
if (!snapshotBooks.length) {
books.clear();
strip.hidden = true;
strip.dataset.connection = "complete";
return;
}
snapshotBooks.forEach(seedBook);
seedRunsFromSnapshot(progress);
const primarySnapshot = snapshotBooks.find((book) => book.hasActiveRuns || book.HasActiveRuns) || snapshotBooks[0];
const primary = books.get(keyFor(primarySnapshot.projectID ?? primarySnapshot.projectId, primarySnapshot.bookID ?? primarySnapshot.bookId)) || seedBook(primarySnapshot);
const runSummary = runSummaryForBook(primary);
const activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || snapshotBooks.length;
const status = progress.status || progress.Status || primary.status || primary.Status || "";
const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes)
?? primary.totalExpectedScenes
?? 0;
const completed = Math.max(
readNumber(progress.completedScenes ?? progress.CompletedScenes) ?? 0,
runSummary.completed || 0
);
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes)
?? readNumber(primary.failedScenes ?? primary.FailedScenes)
?? readNumber(primarySnapshot.failedScenes ?? primarySnapshot.FailedScenes)
?? 0;
const visibleFailed = Math.max(failed, runSummary.failed || 0);
const active = readNumber(progress.activeScenes ?? progress.ActiveScenes) ?? runSummary.active ?? 0;
const updatedRaw = progress.updatedUtc || progress.UpdatedUtc || runSummary.updatedUtc || "";
const updatedTime = Date.parse(updatedRaw);
const snapshotKey = keyFor(primary.projectId, primary.bookId);
const snapshotScore = completed + visibleFailed;
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 && snapshotScore <= lastSnapshotScore)
|| (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(visibleFailed);
strip.dataset.totalExpectedScenes = String(total);
strip.dataset.projectId = String(primary.projectId || "");
strip.dataset.bookId = String(primary.bookId || "");
strip.hidden = false;
strip.dataset.connection = "live";
const elapsedMs = runSummary.durationMs || 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, visibleFailed, total, status);
const failureText = visibleFailed > 0 ? ` \u00b7 ${formatCount(visibleFailed)} 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 = visibleFailed > 0 ? "Story Intelligence completed with notes" : "Story Intelligence complete";
} else if (activeBookCount > 1) {
title.textContent = `${formatCount(activeBookCount)} books analysing`;
} else {
title.textContent = `${primary.bookTitle || "Book"}: ${formatCount(completed)}/${formatCount(Math.max(total, completed))} scenes`;
}
}
log("applied snapshot", { completed, failed: visibleFailed, total, active, status, updatedUtc: updatedRaw });
};
const applyRunProgress = (state) => {
const runId = readNumber(field(state, "runID", "RunID") ?? field(state, "runId", "RunId"));
if (!runId) {
return;
}
const existing = runs.get(String(runId)) || {};
const projectId = readNumber(field(state, "projectID", "ProjectID") ?? field(state, "projectId", "ProjectId")) ?? existing.projectId;
const bookId = readNumber(field(state, "bookID", "BookID") ?? field(state, "bookId", "BookId")) ?? existing.bookId;
const bookKey = keyFor(projectId, bookId);
const book = books.get(bookKey);
runs.set(String(runId), {
runId,
projectId,
bookId,
bookTitle: existing.bookTitle || book?.bookTitle || title?.textContent?.split(":")[0] || "Book",
status: field(state, "status", "Status") || existing.status || "Pending",
total: readNumber(field(state, "totalDetectedScenes", "TotalDetectedScenes")) ?? existing.total ?? 0,
completed: readNumber(field(state, "completedScenes", "CompletedScenes")) ?? existing.completed ?? 0,
failed: readNumber(field(state, "failedScenes", "FailedScenes")) ?? existing.failed ?? 0,
durationMs: readNumber(field(state, "totalDurationMs", "TotalDurationMs")) ?? existing.durationMs ?? 0,
updatedUtc: field(state, "updatedUtc", "UpdatedUtc") || new Date().toISOString()
});
if (!book) {
scheduleRefresh();
return;
}
const summary = runSummaryForBook(book);
render({
books: [{
projectID: book.projectId,
bookID: book.bookId,
bookTitle: book.bookTitle,
hasActiveRuns: isActiveStatus(summary.status),
totalExpectedScenes: book.totalExpectedScenes,
completedScenes: summary.completed,
failedScenes: summary.failed,
activeScenes: summary.active,
totalDurationMs: summary.durationMs,
status: summary.status,
updatedUtc: summary.updatedUtc
}],
activeBookCount: [...books.values()].filter((item) => isActiveStatus(runSummaryForBook(item).status)).length || 1,
totalExpectedScenes: book.totalExpectedScenes,
completedScenes: summary.completed,
failedScenes: summary.failed,
activeScenes: summary.active,
status: summary.status,
updatedUtc: summary.updatedUtc
});
};
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();
seedBooksFromDom();
seedRunsFromDom();
connection.on("StoryIntelligenceRunProgressChanged", (state) => {
log("live run progress received", state);
applyRunProgress(state);
});
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();
})();