(() => { const progressRoots = [...document.querySelectorAll("[data-story-intelligence-progress]")]; const dashboardRoots = [...document.querySelectorAll("[data-story-intelligence-dashboard]")]; const onboardingRoots = [...document.querySelectorAll("[data-story-intelligence-onboarding]")]; const legacyRoots = [...progressRoots, ...dashboardRoots]; const roots = [...legacyRoots, ...onboardingRoots]; if (roots.length === 0 || !window.signalR) { return; } const field = (state, camel, pascal) => state?.[camel] ?? state?.[pascal] ?? null; const legacyJobIds = [...new Set(legacyRoots.map((root) => root.dataset.storyIntelligenceJobId).filter(Boolean))]; const runIds = [...new Set(onboardingRoots .flatMap((root) => (root.dataset.storyIntelligenceRunIds || "").split(",")) .map((value) => value.trim()) .filter(Boolean))]; const numberText = (value) => { const number = Number.parseInt(value ?? "0", 10); return Number.isFinite(number) ? number.toLocaleString() : "0"; }; const friendlyStatus = (status) => { switch (status) { case "Pending": return "Waiting"; case "Running": return "Reading"; case "Completed": return "Ready to review"; case "CompletedWithWarnings": return "Ready to review, with notes"; case "Failed": return "Needs attention"; case "Cancelled": return "Cancelled"; default: return "Waiting"; } }; const friendlyStage = (stage, status) => { if (status === "Completed" || status === "CompletedWithWarnings") { return "Ready to review"; } if (status === "Failed") { return "Needs attention"; } if (status === "Cancelled") { return "Cancelled"; } switch (stage) { case "Queued": return "Waiting"; case "DocumentRead": return "Preparing chapter"; case "ChapterStructure": return "Finding scenes"; case "SceneSplit": return "Preparing scenes"; case "SceneIntelligence": return "Reading scenes"; case "Validation": return "Checking results"; case "Persistence": return "Saving analysis"; default: return "Reading"; } }; const friendlyRemaining = (value, completedScenes, totalScenes) => { if (totalScenes > 0 && completedScenes >= totalScenes) { return "Almost done"; } if (!value || value === "Calculating") { return "Estimating..."; } const minutes = Number.parseInt(String(value).replace(/[^0-9]/g, ""), 10); if (!Number.isFinite(minutes) || minutes <= 0) { return "Estimating..."; } return `About ${minutes}-${Math.max(minutes + 1, minutes * 2)} minutes`; }; const possibleDiscoveryText = (text) => String(text || "") .replace(/^Character detected:/i, "Possible character found:") .replace(/^Possible character detected:/i, "Possible character found:") .replace(/^Location detected:/i, "Possible location found:") .replace(/^Possible location detected:/i, "Possible location found:") .replace(/^Asset detected:/i, "Possible asset found:") .replace(/^Possible asset detected:/i, "Possible asset found:") .replace(/^Timeline clue detected:/i, "Timeline clue found:") .replace(/^Relationship detected:/i, "Relationship signal found:"); const friendlyMessage = (message, stage, status, sceneNumber) => { if (status === "Cancelled") { return "Analysis was cancelled. Any completed chapters remain available for review."; } if (status === "Failed") { return "This chapter needs attention before scenes can be created."; } if (status === "Completed" || status === "CompletedWithWarnings") { return "This chapter is ready to review."; } switch (stage) { case "Queued": return "Waiting to read this chapter."; case "DocumentRead": return "Preparing this chapter."; case "ChapterStructure": return "Finding scenes in this chapter."; case "SceneSplit": return "Preparing the detected scenes."; case "SceneIntelligence": return sceneNumber ? `Reading scene ${numberText(sceneNumber)}.` : "Reading the detected scenes."; case "Validation": return "Checking the results."; default: return message || "Waiting for analysis to begin."; } }; const updateLegacyRoot = (root, state) => { const percent = field(state, "progressPercent", "ProgressPercent") ?? 0; const status = field(state, "status", "Status") || "Pending"; const stage = field(state, "currentStage", "CurrentStage") || status; const message = field(state, "currentMessage", "CurrentMessage") || "Story Intelligence is preparing."; const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec"; const remaining = field(state, "estimatedRemaining", "EstimatedRemaining") || "Calculating"; root.querySelectorAll("[data-story-intelligence-message]").forEach((node) => { node.textContent = message; }); root.querySelectorAll("[data-story-intelligence-percent]").forEach((node) => { node.textContent = `${percent}%`; }); root.querySelectorAll("[data-story-intelligence-progress-bar]").forEach((node) => { node.style.width = `${Math.max(0, Math.min(100, Number.parseInt(percent, 10) || 0))}%`; }); root.querySelectorAll("[data-story-intelligence-stage]").forEach((node) => { node.textContent = stage; }); root.querySelectorAll("[data-story-intelligence-status]").forEach((node) => { node.textContent = status; }); root.querySelectorAll("[data-story-intelligence-elapsed]").forEach((node) => { node.textContent = elapsed; }); root.querySelectorAll("[data-story-intelligence-remaining]").forEach((node) => { node.textContent = remaining; }); const completed = !!field(state, "isCompleted", "IsCompleted"); root.querySelectorAll("[data-story-intelligence-complete-link]").forEach((node) => { node.classList.toggle("disabled", !completed); node.setAttribute("aria-disabled", String(!completed)); }); }; const applyLegacyProgress = (state) => { const jobId = field(state, "jobID", "JobID") || field(state, "jobId", "JobId"); if (!jobId) { return; } legacyRoots .filter((root) => root.dataset.storyIntelligenceJobId?.toLowerCase() === String(jobId).toLowerCase()) .forEach((root) => updateLegacyRoot(root, state)); if (progressRoots.length > 0 && field(state, "isCompleted", "IsCompleted")) { window.location.href = `/onboarding/story-intelligence/complete?jobId=${encodeURIComponent(jobId)}`; } }; const statusLabel = (state) => { const status = field(state, "status", "Status") || ""; if (status === "Completed" || status === "CompletedWithWarnings") { return "Ready to create"; } if (status === "Failed" || status === "Cancelled") { return "Needs attention"; } if (status === "Pending" || status === "Running") { return "Analysing"; } return "Reviewing"; }; const addFeedItem = (list, text) => { if (!list || !text) { return; } const item = document.createElement("li"); item.textContent = possibleDiscoveryText(text); list.prepend(item); while (list.children.length > 8) { list.lastElementChild?.remove(); } }; const recalculateOnboardingTotals = (root) => { const hadActiveRuns = root.dataset.storyIntelligenceHasActiveRuns === "true"; const chapters = [...root.querySelectorAll("[data-story-run-id]")]; const totals = chapters.reduce((current, chapter) => { const status = chapter.dataset.storyRawStatus || ""; const completed = Number.parseInt(chapter.querySelector("[data-story-run-completed]")?.textContent?.replace(/,/g, "") || "0", 10) || 0; const total = Number.parseInt(chapter.querySelector("[data-story-run-total]")?.textContent?.replace(/,/g, "") || "0", 10) || 0; const failed = Number.parseInt(chapter.querySelector("[data-story-run-failed]")?.textContent?.replace(/,/g, "") || "0", 10) || 0; current.completedChapters += status === "Completed" || status === "CompletedWithWarnings" ? 1 : 0; current.failedChapters += status === "Failed" || status === "Cancelled" ? 1 : 0; current.completedScenes += completed; current.totalScenes += total; current.failedScenes += failed; return current; }, { completedChapters: 0, failedChapters: 0, completedScenes: 0, totalScenes: 0, failedScenes: 0 }); const chapterProgress = chapters.reduce((total, chapter) => { const status = chapter.dataset.storyRawStatus || ""; if (status === "Completed" || status === "CompletedWithWarnings" || status === "Failed" || status === "Cancelled") { return total + 1; } if (status === "Pending") { return total; } const completed = Number.parseInt(chapter.querySelector("[data-story-run-completed]")?.textContent?.replace(/,/g, "") || "0", 10) || 0; const detected = Number.parseInt(chapter.querySelector("[data-story-run-total]")?.textContent?.replace(/,/g, "") || "0", 10) || 0; if (detected > 0) { return total + Math.min(0.95, 0.25 + ((completed / Math.max(1, detected)) * 0.7)); } return total + 0.15; }, 0); const calculatedPercent = Math.floor((chapterProgress * 100) / Math.max(1, chapters.length)); const previousPercent = Number.parseInt(root.dataset.storyMaxProgressPercent || "0", 10) || 0; const percent = Math.max(previousPercent, Math.max(0, Math.min(100, calculatedPercent))); root.dataset.storyMaxProgressPercent = String(percent); root.querySelectorAll("[data-story-overall-percent]").forEach((node) => { node.textContent = `${Math.max(0, Math.min(100, percent))}%`; }); root.querySelectorAll("[data-story-overall-progress-bar]").forEach((node) => { node.style.width = `${Math.max(0, Math.min(100, percent))}%`; }); root.querySelectorAll("[data-story-chapters-completed]").forEach((node) => { node.textContent = numberText(totals.completedChapters); }); root.querySelectorAll("[data-story-scenes-completed]").forEach((node) => { node.textContent = numberText(totals.completedScenes); }); root.querySelectorAll("[data-story-scenes-total]").forEach((node) => { node.textContent = numberText(totals.totalScenes); }); root.querySelectorAll("[data-story-failures]").forEach((node) => { node.textContent = numberText(totals.failedChapters + totals.failedScenes); }); const active = chapters.some((chapter) => { const status = chapter.dataset.storyRawStatus || ""; return status === "Pending" || status === "Running"; }); root.dataset.storyIntelligenceHasActiveRuns = String(active); if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") { root.dataset.reloadScheduled = "true"; window.setTimeout(() => window.location.reload(), 1200); } }; const applyRunProgress = (state) => { const runId = field(state, "runID", "RunID") || field(state, "runId", "RunId"); if (!runId) { return; } onboardingRoots.forEach((root) => { const chapter = root.querySelector(`[data-story-run-id="${runId}"]`); if (!chapter) { return; } const status = field(state, "status", "Status") || "Pending"; const stage = field(state, "currentStage", "CurrentStage") || status; const message = field(state, "currentMessage", "CurrentMessage") || ""; const completed = field(state, "completedScenes", "CompletedScenes"); const total = field(state, "totalDetectedScenes", "TotalDetectedScenes"); const failed = field(state, "failedScenes", "FailedScenes"); const tokens = field(state, "totalTokens", "TotalTokens"); const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec"; const remaining = field(state, "estimatedRemaining", "EstimatedRemaining") || "Calculating"; const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber"); const displayMessage = friendlyMessage(message, stage, status, currentSceneNumber); const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary"); const discoveries = field(state, "recentDiscoveries", "RecentDiscoveries") || []; const error = field(state, "errorMessage", "ErrorMessage"); chapter.querySelectorAll("[data-story-chapter-status-label]").forEach((node) => { node.textContent = statusLabel(state); }); chapter.dataset.storyRawStatus = status; chapter.querySelectorAll("[data-story-run-status]").forEach((node) => { node.textContent = friendlyStatus(status); }); chapter.querySelectorAll("[data-story-run-raw-status]").forEach((node) => { node.textContent = status; }); chapter.querySelectorAll("[data-story-run-raw-stage]").forEach((node) => { node.textContent = stage; }); chapter.querySelectorAll("[data-story-run-message]").forEach((node) => { node.textContent = displayMessage; }); chapter.querySelectorAll("[data-story-run-completed]").forEach((node) => { if (completed !== null) { node.textContent = numberText(completed); } }); chapter.querySelectorAll("[data-story-run-total]").forEach((node) => { if (total !== null) { node.textContent = numberText(total); } }); chapter.querySelectorAll("[data-story-run-failed]").forEach((node) => { if (failed !== null) { node.textContent = numberText(failed); } }); chapter.querySelectorAll("[data-story-run-review-note]").forEach((node) => { const failedCount = Number.parseInt(failed || "0", 10) || 0; node.textContent = failedCount > 0 ? "Needs review after analysis completes." : ""; node.hidden = failedCount <= 0; }); chapter.querySelectorAll("[data-story-run-tokens]").forEach((node) => { node.textContent = tokens === null ? "-" : numberText(tokens); }); chapter.querySelectorAll("[data-story-run-error]").forEach((node) => { node.textContent = error || ""; node.hidden = !error; }); root.querySelectorAll("[data-story-current-chapter]").forEach((node) => { node.textContent = chapter.dataset.storyChapterTitle || "Current chapter"; }); root.querySelectorAll("[data-story-current-stage]").forEach((node) => { node.textContent = friendlyStage(stage, status); }); root.querySelectorAll("[data-story-current-scene]").forEach((node) => { node.textContent = currentSceneNumber ? numberText(currentSceneNumber) : "Waiting"; }); root.querySelectorAll("[data-story-current-message]").forEach((node) => { node.textContent = displayMessage; }); root.querySelectorAll("[data-story-elapsed]").forEach((node) => { node.textContent = elapsed; }); root.querySelectorAll("[data-story-remaining]").forEach((node) => { node.textContent = friendlyRemaining(remaining, Number.parseInt(completed || "0", 10) || 0, Number.parseInt(total || "0", 10) || 0); }); const summaryNode = root.querySelector("[data-story-latest-summary]"); if (summaryNode && latestSummary) { summaryNode.textContent = latestSummary; summaryNode.hidden = false; const emptyNode = root.querySelector("[data-story-latest-empty]"); if (emptyNode) { emptyNode.hidden = true; } } const feed = root.querySelector("[data-story-discovery-feed]"); [...discoveries].reverse().forEach((discovery) => addFeedItem(feed, discovery)); recalculateOnboardingTotals(root); }); }; const connection = new signalR.HubConnectionBuilder() .withUrl("/hubs/story-intelligence") .withAutomaticReconnect() .build(); connection.on("StoryIntelligenceProgressChanged", applyLegacyProgress); connection.on("StoryIntelligenceRunProgressChanged", applyRunProgress); connection.onreconnected(() => { legacyJobIds.forEach((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId).then(applyLegacyProgress).catch(() => {})); runIds.forEach((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10)).then(applyRunProgress).catch(() => {})); }); connection.start() .then(() => Promise.all([ ...legacyJobIds.map((jobId) => connection.invoke("WatchStoryIntelligenceJob", jobId)), ...runIds.map((runId) => connection.invoke("WatchStoryIntelligenceRun", Number.parseInt(runId, 10))) ])) .then((states) => { states.filter(Boolean).forEach((state) => { if (field(state, "runID", "RunID") || field(state, "runId", "RunId")) { applyRunProgress(state); } else { applyLegacyProgress(state); } }); }) .catch(() => {}); })();