Fix Story Intelligence global progress totals

This commit is contained in:
Nick Beckley 2026-09-04 21:50:24 +00:00
parent 738c1d118f
commit 390ea19550
5 changed files with 547 additions and 44 deletions

View File

@ -1,4 +1,5 @@
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
@ -26,9 +27,36 @@ public sealed class StoryIntelligenceGlobalProgressService(
public async Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId)
{
var summaries = await runs.ListActiveBookRunSummariesForUserAsync(userId);
return new StoryIntelligenceGlobalProgressViewModel
var books = new List<StoryIntelligenceGlobalProgressBookViewModel>();
foreach (var summary in summaries)
{
Books = summaries.Select(summary => new StoryIntelligenceGlobalProgressBookViewModel
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,
@ -44,9 +72,14 @@ public sealed class StoryIntelligenceGlobalProgressService(
ActiveScenes = summary.ActiveScenes,
Status = summary.Status,
TotalDurationMs = summary.TotalDurationMs,
UpdatedUtc = summary.UpdatedUtc
})
.ToList()
UpdatedUtc = summary.UpdatedUtc,
Runs = relevantRuns
});
}
return new StoryIntelligenceGlobalProgressViewModel
{
Books = books
};
}
}

View File

@ -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

View File

@ -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<StoryIntelligenceGlobalProgressRunViewModel> 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;
}

View File

@ -270,6 +270,36 @@
<a class="btn btn-outline-secondary btn-sm" asp-controller="Books" asp-action="Details" asp-route-id="@progressBookId">Book</a>
}
</div>
<div hidden aria-hidden="true" data-story-intelligence-global-runs>
@foreach (var book in storyIntelligenceProgress.Books)
{
<span data-story-intelligence-global-book
data-project-id="@book.ProjectID"
data-book-id="@book.BookID"
data-book-title="@book.BookTitle"
data-status="@book.Status"
data-total-expected-scenes="@book.TotalExpectedScenes"
data-completed-scenes="@book.CompletedScenes"
data-failed-scenes="@book.FailedScenes"
data-active-scenes="@book.ActiveScenes"
data-total-duration-ms="@book.TotalDurationMs"
data-updated-utc="@book.UpdatedUtc.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture)"></span>
@foreach (var run in book.Runs)
{
<span data-story-intelligence-global-run
data-run-id="@run.RunID"
data-project-id="@run.ProjectID"
data-book-id="@run.BookID"
data-book-title="@run.BookTitle"
data-status="@run.Status"
data-total-detected-scenes="@run.TotalDetectedScenes"
data-completed-scenes="@run.CompletedScenes"
data-failed-scenes="@run.FailedScenes"
data-total-duration-ms="@run.TotalDurationMs"
data-updated-utc="@run.UpdatedUtc.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture)"></span>
}
}
</div>
</div>
}
</header>

View File

@ -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);