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