(() => { 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 friendlyRemainingFromSeconds = (seconds) => { const minutes = Math.ceil(seconds / 60); if (!Number.isFinite(minutes) || minutes <= 0) { return "Estimating..."; } if (minutes <= 20) { return "About 20-30 minutes"; } if (minutes <= 45) { return "About 45-60 minutes"; } if (minutes <= 120) { return "About 1-2 hours"; } return "More than 2 hours"; }; const calculateRemaining = (chapters, hasActiveRuns) => { if (!hasActiveRuns) { return "Ready to review"; } const completed = chapters.filter((chapter) => { const status = chapter.dataset.storyRawStatus || ""; const words = Number.parseInt(chapter.dataset.storySourceWordCount || "0", 10) || 0; const duration = Number.parseInt(chapter.dataset.storyDurationMs || "0", 10) || 0; return (status === "Completed" || status === "CompletedWithWarnings") && words > 0 && duration > 0; }); if (completed.length === 0) { return "Estimating..."; } const completedWords = completed.reduce((total, chapter) => total + (Number.parseInt(chapter.dataset.storySourceWordCount || "0", 10) || 0), 0); const completedSeconds = completed.reduce((total, chapter) => total + ((Number.parseInt(chapter.dataset.storyDurationMs || "0", 10) || 0) / 1000), 0); const remainingWords = chapters.reduce((total, chapter) => { const status = chapter.dataset.storyRawStatus || ""; if (status === "Completed" || status === "CompletedWithWarnings" || status === "Failed" || status === "Cancelled") { return total; } return total + (Number.parseInt(chapter.dataset.storySourceWordCount || "0", 10) || 0); }, 0); if (completedWords <= 0 || completedSeconds <= 0 || remainingWords <= 0) { return "Estimating..."; } return friendlyRemainingFromSeconds((completedSeconds / completedWords) * remainingWords * 1.35); }; 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); const placeholder = list.closest(".story-live-discoveries")?.querySelector("[data-story-discovery-empty]"); if (placeholder) { placeholder.hidden = true; } while (list.children.length > 8) { list.lastElementChild?.remove(); } }; const parseNumber = (value) => Number.parseFloat(String(value || "").replace(/,/g, "")) || 0; const parseTime = (value) => { const parsed = Date.parse(value || ""); return Number.isNaN(parsed) ? 0 : parsed; }; const isActiveStatus = (status) => status === "Pending" || status === "Running"; const authoritativeChapter = (chapters) => { const active = chapters .filter((chapter) => isActiveStatus(chapter.dataset.storyRawStatus || "") && parseNumber(chapter.dataset.storyCurrentSceneNumber) > 0) .sort((left, right) => { const byUpdated = parseTime(right.dataset.storyUpdatedUtc) - parseTime(left.dataset.storyUpdatedUtc); if (byUpdated !== 0) return byUpdated; return parseNumber(right.dataset.storyChapterNumber) - parseNumber(left.dataset.storyChapterNumber); }); if (active.length > 0) return active[0]; const completed = chapters .filter((chapter) => { const status = chapter.dataset.storyRawStatus || ""; return status === "Completed" || status === "CompletedWithWarnings"; }) .sort((left, right) => { const byUpdated = parseTime(right.dataset.storyUpdatedUtc) - parseTime(left.dataset.storyUpdatedUtc); if (byUpdated !== 0) return byUpdated; return parseNumber(right.dataset.storyChapterNumber) - parseNumber(left.dataset.storyChapterNumber); }); return completed[0] || chapters[0] || null; }; const applyAuthoritativeCursor = (root) => { const chapters = [...root.querySelectorAll("[data-story-run-id]")]; const chapter = authoritativeChapter(chapters); if (!chapter) return; const status = chapter.dataset.storyRawStatus || "Pending"; const stage = chapter.dataset.storyCurrentStage || status; const currentSceneNumber = chapter.dataset.storyCurrentSceneNumber || ""; const total = chapter.querySelector("[data-story-run-total]")?.textContent || ""; const completed = chapter.querySelector("[data-story-run-completed]")?.textContent || ""; const message = chapter.querySelector("[data-story-run-message]")?.textContent || ""; 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"; }); const totalNumber = Number.parseInt(total.replace(/,/g, "") || "0", 10) || 0; const completedNumber = Number.parseInt(completed.replace(/,/g, "") || "0", 10) || 0; const currentSceneDisplay = currentSceneNumber ? Number.parseInt(currentSceneNumber, 10) || completedNumber : completedNumber; root.querySelectorAll("[data-story-chapter-progress-text]").forEach((node) => { node.textContent = totalNumber > 0 ? `Scene ${numberText(Math.min(Math.max(currentSceneDisplay, 0), totalNumber))} of ${numberText(totalNumber)}` : "Finding scenes..."; }); root.querySelectorAll("[data-story-current-message]").forEach((node) => { node.textContent = message; }); }; 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-read-value]").forEach((node) => { node.textContent = `${numberText(totals.completedChapters)} of ${numberText(chapters.length)}`; }); root.querySelectorAll("[data-story-scenes-read-value]").forEach((node) => { node.textContent = `${numberText(totals.completedScenes)} so far`; }); 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); root.querySelectorAll("[data-story-remaining]").forEach((node) => { node.textContent = calculateRemaining(chapters, active); }); applyAuthoritativeCursor(root); 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 totalDurationMs = field(state, "totalDurationMs", "TotalDurationMs"); const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec"; const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber"); const updatedUtc = field(state, "updatedUtc", "UpdatedUtc"); 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.dataset.storyCurrentStage = stage; chapter.dataset.storyCurrentSceneNumber = completed || "0"; if (updatedUtc) { chapter.dataset.storyUpdatedUtc = updatedUtc; } if (totalDurationMs !== null) { chapter.dataset.storyDurationMs = String(totalDurationMs); } 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; }); chapter.querySelectorAll("[data-story-run-latest-summary]").forEach((node) => { if (latestSummary) { node.textContent = latestSummary; } }); const totalNumber = Number.parseInt(total || "0", 10) || 0; const completedNumber = Number.parseInt(completed || "0", 10) || 0; root.querySelectorAll("[data-story-chapter-progress-bar]").forEach((node) => { const percent = totalNumber > 0 ? Math.floor((Math.min(completedNumber, totalNumber) * 100) / totalNumber) : 0; node.style.width = `${Math.max(0, Math.min(100, percent))}%`; }); root.querySelectorAll("[data-story-elapsed]").forEach((node) => { node.textContent = elapsed; }); 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(() => {}); })();