diff --git a/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs b/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs index e6a6d69..e2d4060 100644 --- a/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs +++ b/PlotLine/Services/StoryIntelligenceGlobalProgressService.cs @@ -1,4 +1,5 @@ using PlotLine.Data; +using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; @@ -26,27 +27,59 @@ public sealed class StoryIntelligenceGlobalProgressService( public async Task GetForUserAsync(int userId) { var summaries = await runs.ListActiveBookRunSummariesForUserAsync(userId); + var books = new List(); + foreach (var summary in summaries) + { + var bookRuns = summary.BookID.HasValue + ? await runs.ListRunsByBookForUserAsync(summary.BookID.Value, userId) + : []; + var relevantRuns = bookRuns + .Where(run => run.CreatedUtc >= summary.FirstCreatedUtc + && run.Status is StoryIntelligenceRunStatuses.Pending + or StoryIntelligenceRunStatuses.Running + or StoryIntelligenceRunStatuses.Completed + or StoryIntelligenceRunStatuses.CompletedWithWarnings + or StoryIntelligenceRunStatuses.Failed + or StoryIntelligenceRunStatuses.Cancelled) + .Select(run => new StoryIntelligenceGlobalProgressRunViewModel + { + RunID = run.StoryIntelligenceRunID, + ProjectID = run.ProjectID, + BookID = run.BookID, + BookTitle = string.IsNullOrWhiteSpace(summary.BookDisplayTitle) ? "Book" : summary.BookDisplayTitle, + Status = string.IsNullOrWhiteSpace(run.Status) ? StoryIntelligenceRunStatuses.Pending : run.Status, + TotalDetectedScenes = run.TotalDetectedScenes ?? 0, + CompletedScenes = run.CompletedScenes ?? 0, + FailedScenes = run.FailedScenes ?? 0, + TotalDurationMs = run.TotalDurationMs ?? 0, + UpdatedUtc = run.UpdatedUtc + }) + .ToList(); + + books.Add(new StoryIntelligenceGlobalProgressBookViewModel + { + ProjectID = summary.ProjectID, + BookID = summary.BookID, + BookTitle = string.IsNullOrWhiteSpace(summary.BookDisplayTitle) ? "Book" : summary.BookDisplayTitle, + RunCount = summary.RunCount, + PendingRunCount = summary.PendingRunCount, + RunningRunCount = summary.RunningRunCount, + CompletedRunCount = summary.CompletedRunCount, + FailedRunCount = summary.FailedRunCount, + TotalExpectedScenes = summary.TotalExpectedScenes, + CompletedScenes = summary.CompletedScenes, + FailedScenes = summary.FailedScenes, + ActiveScenes = summary.ActiveScenes, + Status = summary.Status, + TotalDurationMs = summary.TotalDurationMs, + UpdatedUtc = summary.UpdatedUtc, + Runs = relevantRuns + }); + } + return new StoryIntelligenceGlobalProgressViewModel { - Books = summaries.Select(summary => new StoryIntelligenceGlobalProgressBookViewModel - { - ProjectID = summary.ProjectID, - BookID = summary.BookID, - BookTitle = string.IsNullOrWhiteSpace(summary.BookDisplayTitle) ? "Book" : summary.BookDisplayTitle, - RunCount = summary.RunCount, - PendingRunCount = summary.PendingRunCount, - RunningRunCount = summary.RunningRunCount, - CompletedRunCount = summary.CompletedRunCount, - FailedRunCount = summary.FailedRunCount, - TotalExpectedScenes = summary.TotalExpectedScenes, - CompletedScenes = summary.CompletedScenes, - FailedScenes = summary.FailedScenes, - ActiveScenes = summary.ActiveScenes, - Status = summary.Status, - TotalDurationMs = summary.TotalDurationMs, - UpdatedUtc = summary.UpdatedUtc - }) - .ToList() + Books = books }; } } diff --git a/PlotLine/Sql/186_Phase27M_AuthoritativeGlobalProgressCompletedScenes.sql b/PlotLine/Sql/186_Phase27M_AuthoritativeGlobalProgressCompletedScenes.sql new file mode 100644 index 0000000..6de9f39 --- /dev/null +++ b/PlotLine/Sql/186_Phase27M_AuthoritativeGlobalProgressCompletedScenes.sql @@ -0,0 +1,225 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_StoryIntelligenceSceneResults_BookSceneRun' AND object_id = OBJECT_ID(N'dbo.StoryIntelligenceSceneResults')) +BEGIN + CREATE INDEX IX_StoryIntelligenceSceneResults_BookSceneRun + ON dbo.StoryIntelligenceSceneResults(BookID, SceneID, StoryIntelligenceRunID) + INCLUDE (ProjectID, ChapterID, CreatedUtc); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ListActiveBookSummaryForUser + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + ;WITH RunScopes AS + ( + SELECT + r.UserID, + r.ProjectID, + r.BookID, + LatestCreatedUtc = MAX(r.CreatedUtc), + LatestUpdatedUtc = MAX(r.UpdatedUtc) + FROM dbo.StoryIntelligenceRuns r + INNER JOIN dbo.Projects p ON p.ProjectID = r.ProjectID AND p.IsArchived = 0 + INNER JOIN dbo.Books b ON b.BookID = r.BookID AND b.IsArchived = 0 + INNER JOIN dbo.ProjectUserAccess access + ON access.ProjectID = r.ProjectID + AND access.UserID = @UserID + AND access.IsActive = 1 + WHERE r.UserID = @UserID + AND r.BookID IS NOT NULL + AND r.Status IN (N'Pending', N'Running') + GROUP BY r.UserID, r.ProjectID, r.BookID + ), + PipelineScopes AS + ( + SELECT + @UserID AS UserID, + p.ProjectID, + p.BookID, + LatestCreatedUtc = MAX(COALESCE(lastRun.CreatedUtc, p.CreatedUtc)), + LatestUpdatedUtc = MAX(CASE + WHEN lastRun.UpdatedUtc IS NOT NULL AND lastRun.UpdatedUtc > p.UpdatedUtc THEN lastRun.UpdatedUtc + ELSE p.UpdatedUtc + END) + FROM dbo.StoryIntelligenceBookPipelines p + INNER JOIN dbo.Projects project ON project.ProjectID = p.ProjectID AND project.IsArchived = 0 + INNER JOIN dbo.Books book ON book.BookID = p.BookID AND book.IsArchived = 0 + INNER JOIN dbo.ProjectUserAccess access + ON access.ProjectID = p.ProjectID + AND access.UserID = @UserID + AND access.IsActive = 1 + LEFT JOIN dbo.StoryIntelligenceRuns lastRun ON lastRun.StoryIntelligenceRunID = p.LastRunID + WHERE p.Status = N'InProgress' + GROUP BY p.ProjectID, p.BookID + ), + ActiveScopes AS + ( + SELECT + scopes.UserID, + scopes.ProjectID, + scopes.BookID, + LatestCreatedUtc = MAX(scopes.LatestCreatedUtc), + LatestUpdatedUtc = MAX(scopes.LatestUpdatedUtc) + FROM + ( + SELECT UserID, ProjectID, BookID, LatestCreatedUtc, LatestUpdatedUtc FROM RunScopes + UNION ALL + SELECT UserID, ProjectID, BookID, LatestCreatedUtc, LatestUpdatedUtc FROM PipelineScopes + ) scopes + GROUP BY scopes.UserID, scopes.ProjectID, scopes.BookID + ), + ScopedRuns AS + ( + SELECT r.* + FROM dbo.StoryIntelligenceRuns r + INNER JOIN ActiveScopes scope + ON scope.UserID = r.UserID + AND ISNULL(scope.ProjectID, -1) = ISNULL(r.ProjectID, -1) + AND ISNULL(scope.BookID, -1) = ISNULL(r.BookID, -1) + AND r.CreatedUtc >= DATEADD(minute, -30, scope.LatestCreatedUtc) + WHERE r.Status IN (N'Pending', N'Running', N'Completed', N'CompletedWithWarnings', N'Failed', N'Cancelled') + ), + CanonicalSceneScope AS + ( + SELECT DISTINCT + scope.UserID, + scope.ProjectID, + scope.BookID, + s.SceneID + FROM ActiveScopes scope + INNER JOIN dbo.Chapters c + ON c.BookID = scope.BookID + AND c.IsArchived = 0 + INNER JOIN dbo.Scenes s + ON s.ChapterID = c.ChapterID + AND s.IsArchived = 0 + ), + RunSummary AS + ( + SELECT + scope.UserID, + scope.ProjectID, + scope.BookID, + COUNT(r.StoryIntelligenceRunID) AS RunCount, + SUM(CASE WHEN r.Status = N'Pending' THEN 1 ELSE 0 END) AS PendingRunCount, + SUM(CASE WHEN r.Status = N'Running' THEN 1 ELSE 0 END) AS RunningRunCount, + SUM(CASE WHEN r.Status IN (N'Completed', N'CompletedWithWarnings') THEN 1 ELSE 0 END) AS CompletedRunCount, + SUM(CASE WHEN r.Status IN (N'Failed', N'Cancelled') THEN 1 ELSE 0 END) AS FailedRunCount, + SUM(ISNULL(r.TotalDetectedScenes, 0)) AS TotalDetectedScenes, + SUM(ISNULL(r.CompletedScenes, 0)) AS CompletedScenes, + SUM(ISNULL(r.FailedScenes, 0)) AS FailedScenes, + SUM(CASE + WHEN r.Status IN (N'Pending', N'Running') + THEN CASE + WHEN ISNULL(r.TotalDetectedScenes, 0) - ISNULL(r.CompletedScenes, 0) - ISNULL(r.FailedScenes, 0) > 0 + THEN ISNULL(r.TotalDetectedScenes, 0) - ISNULL(r.CompletedScenes, 0) - ISNULL(r.FailedScenes, 0) + ELSE 0 + END + ELSE 0 + END) AS ActiveScenes, + SUM(ISNULL(r.TotalDurationMs, 0)) AS TotalDurationMs, + MIN(COALESCE(r.CreatedUtc, scope.LatestCreatedUtc)) AS FirstCreatedUtc, + MAX(CASE + WHEN r.UpdatedUtc IS NOT NULL AND r.UpdatedUtc > scope.LatestUpdatedUtc THEN r.UpdatedUtc + ELSE scope.LatestUpdatedUtc + END) AS UpdatedUtc + FROM ActiveScopes scope + LEFT JOIN ScopedRuns r + ON r.UserID = scope.UserID + AND ISNULL(r.ProjectID, -1) = ISNULL(scope.ProjectID, -1) + AND ISNULL(r.BookID, -1) = ISNULL(scope.BookID, -1) + GROUP BY scope.UserID, scope.ProjectID, scope.BookID + ), + ExpectedSceneSummary AS + ( + SELECT + scope.UserID, + scope.ProjectID, + scope.BookID, + COUNT(scope.SceneID) AS TotalExpectedScenes + FROM CanonicalSceneScope scope + GROUP BY scope.UserID, scope.ProjectID, scope.BookID + ), + CompletedSceneSummary AS + ( + SELECT + scope.UserID, + scope.ProjectID, + scope.BookID, + COUNT(DISTINCT results.SceneID) AS CompletedScenes + FROM CanonicalSceneScope scope + INNER JOIN dbo.StoryIntelligenceSceneResults results + ON results.SceneID = scope.SceneID + INNER JOIN dbo.StoryIntelligenceRuns runRows + ON runRows.StoryIntelligenceRunID = results.StoryIntelligenceRunID + AND runRows.UserID = scope.UserID + AND ISNULL(runRows.ProjectID, -1) = ISNULL(scope.ProjectID, -1) + AND ISNULL(runRows.BookID, -1) = ISNULL(scope.BookID, -1) + GROUP BY scope.UserID, scope.ProjectID, scope.BookID + ) + SELECT + summary.UserID, + summary.ProjectID, + p.ProjectName AS ProjectTitle, + summary.BookID, + b.BookTitle, + b.Subtitle AS BookSubtitle, + summary.RunCount, + summary.PendingRunCount, + summary.RunningRunCount, + summary.CompletedRunCount, + summary.FailedRunCount, + COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) AS TotalExpectedScenes, + summary.TotalDetectedScenes, + COALESCE(completed.CompletedScenes, summary.CompletedScenes, 0) AS CompletedScenes, + summary.FailedScenes, + summary.ActiveScenes, + CASE + WHEN COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) <= 0 THEN 0 + ELSE CONVERT(int, ROUND(COALESCE(completed.CompletedScenes, summary.CompletedScenes, 0) * 100.0 / COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 1), 0)) + END AS ProgressPercent, + CASE + WHEN pipeline.Status = N'InProgress' + AND summary.RunningRunCount = 0 + AND summary.PendingRunCount = 0 + THEN N'Finalising' + WHEN pipeline.Status = N'InProgress' + AND COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) > 0 + AND COALESCE(completed.CompletedScenes, summary.CompletedScenes, 0) + summary.FailedScenes >= COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) + THEN N'Finalising' + WHEN summary.RunningRunCount > 0 THEN N'Running' + WHEN summary.PendingRunCount > 0 THEN N'Pending' + WHEN summary.FailedRunCount > 0 THEN N'Failed' + WHEN summary.FailedScenes > 0 THEN N'CompletedWithWarnings' + ELSE N'Completed' + END AS Status, + summary.TotalDurationMs, + summary.FirstCreatedUtc, + summary.UpdatedUtc + FROM RunSummary summary + LEFT JOIN ExpectedSceneSummary expected + ON expected.UserID = summary.UserID + AND ISNULL(expected.ProjectID, -1) = ISNULL(summary.ProjectID, -1) + AND ISNULL(expected.BookID, -1) = ISNULL(summary.BookID, -1) + LEFT JOIN CompletedSceneSummary completed + ON completed.UserID = summary.UserID + AND ISNULL(completed.ProjectID, -1) = ISNULL(summary.ProjectID, -1) + AND ISNULL(completed.BookID, -1) = ISNULL(summary.BookID, -1) + LEFT JOIN dbo.StoryIntelligenceBookPipelines pipeline + ON pipeline.BookID = summary.BookID + AND pipeline.ProjectID = summary.ProjectID + LEFT JOIN dbo.Projects p ON p.ProjectID = summary.ProjectID + LEFT JOIN dbo.Books b ON b.BookID = summary.BookID + WHERE summary.PendingRunCount > 0 + OR summary.RunningRunCount > 0 + OR pipeline.Status = N'InProgress' + ORDER BY summary.UpdatedUtc DESC, summary.FirstCreatedUtc; +END; +GO diff --git a/PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs index ba582ea..5475860 100644 --- a/PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceGlobalProgressViewModels.cs @@ -50,6 +50,7 @@ public sealed class StoryIntelligenceGlobalProgressBookViewModel public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending; public long TotalDurationMs { get; init; } public DateTime UpdatedUtc { get; init; } + public IReadOnlyList Runs { get; init; } = []; public bool HasActiveRuns => PendingRunCount > 0 || RunningRunCount > 0 @@ -86,3 +87,22 @@ public sealed class StoryIntelligenceGlobalProgressBookViewModel return $"{Math.Max(1, (int)Math.Round(remaining.TotalSeconds)):N0} sec"; } } + +public sealed class StoryIntelligenceGlobalProgressRunViewModel +{ + public int RunID { get; init; } + public int? ProjectID { get; init; } + public int? BookID { get; init; } + public string BookTitle { get; init; } = "Book"; + public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending; + public int TotalDetectedScenes { get; init; } + public int CompletedScenes { get; init; } + public int FailedScenes { get; init; } + public long TotalDurationMs { get; init; } + public DateTime UpdatedUtc { get; init; } + + public bool IsTerminal => Status is StoryIntelligenceRunStatuses.Completed + or StoryIntelligenceRunStatuses.CompletedWithWarnings + or StoryIntelligenceRunStatuses.Failed + or StoryIntelligenceRunStatuses.Cancelled; +} diff --git a/PlotLine/Views/Shared/_Layout.cshtml b/PlotLine/Views/Shared/_Layout.cshtml index 6fc4ab7..b15cfa1 100644 --- a/PlotLine/Views/Shared/_Layout.cshtml +++ b/PlotLine/Views/Shared/_Layout.cshtml @@ -270,6 +270,36 @@ Book } + } diff --git a/PlotLine/wwwroot/js/story-intelligence-global-progress.js b/PlotLine/wwwroot/js/story-intelligence-global-progress.js index 2a37d75..4146e55 100644 --- a/PlotLine/wwwroot/js/story-intelligence-global-progress.js +++ b/PlotLine/wwwroot/js/story-intelligence-global-progress.js @@ -27,8 +27,14 @@ (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"; @@ -52,33 +58,160 @@ 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 books = progress?.books || progress?.Books || []; - if (!books.length) { + const snapshotBooks = progress?.books || progress?.Books || []; + if (!snapshotBooks.length) { + books.clear(); 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; + 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) - ?? readNumber(primary.totalExpectedScenes ?? primary.TotalExpectedScenes) - ?? 0; - const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) - ?? readNumber(primary.completedScenes ?? primary.CompletedScenes) + ?? 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 active = readNumber(progress.activeScenes ?? progress.ActiveScenes) - ?? readNumber(primary.activeScenes ?? primary.ActiveScenes) - ?? 0; - const updatedRaw = progress.updatedUtc || progress.UpdatedUtc || primary.updatedUtc || primary.UpdatedUtc || ""; + 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 = `${primary.projectID ?? primary.projectId ?? ""}:${primary.bookID ?? primary.bookId ?? ""}`; - const snapshotScore = completed + failed; + const snapshotKey = keyFor(primary.projectId, primary.bookId); + const snapshotScore = completed + visibleFailed; if (snapshotKey !== lastSnapshotKey) { lastSnapshotKey = snapshotKey; lastSnapshotScore = 0; @@ -91,7 +224,8 @@ } if (Number.isFinite(updatedTime)) { - if (updatedTime < lastSnapshotTime || (updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore)) { + if ((updatedTime < lastSnapshotTime && snapshotScore <= lastSnapshotScore) + || (updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore)) { log("ignored stale snapshot", { completed, failed, updatedUtc: updatedRaw, snapshotScore, lastSnapshotScore }); return; } @@ -104,13 +238,14 @@ 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.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 = readNumber(primary.totalDurationMs ?? primary.TotalDurationMs) || 0; + const elapsedMs = runSummary.durationMs || 0; const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0; if (bar) { @@ -118,8 +253,8 @@ } if (detail) { - const remaining = estimateRemaining(elapsedMs, completed, failed, total, status); - const failureText = failed > 0 ? ` \u00b7 ${formatCount(failed)} failed` : ""; + 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`; } @@ -132,14 +267,68 @@ } 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"; + 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.summaryText || primary.SummaryText || title.textContent; + title.textContent = `${primary.bookTitle || "Book"}: ${formatCount(completed)}/${formatCount(Math.max(total, completed))} scenes`; } } - log("applied snapshot", { completed, failed, total, active, status, updatedUtc: updatedRaw }); + 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 = () => { @@ -164,7 +353,13 @@ .withAutomaticReconnect() .build(); - connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh); + 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);