(() => { 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(); })();