Fix story intelligence progress totals

This commit is contained in:
Nick Beckley 2026-08-28 19:47:05 +00:00
parent 96343138ea
commit 0cc25c570c
8 changed files with 301 additions and 42 deletions

View File

@ -231,7 +231,8 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard), ("Story Intelligence review forms share busy submit guard", StoryIntelligenceReviewFormsShareBusySubmitGuard),
("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering), ("Story Intelligence heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls), ("Story Intelligence Review Centre adds no AI calls", StoryIntelligenceReviewCentreAddsNoAiCalls),
("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution) ("Story Intelligence Job 6 uses bounded concurrent chapter execution", StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution),
("Story Intelligence global progress uses whole run scene totals", StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals)
}; };
foreach (var test in tests) foreach (var test in tests)
@ -270,6 +271,77 @@ static void StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution()
Assert(openAiClient.Contains("RetryConditionHeaderValue", StringComparison.Ordinal), "OpenAI retry should honour Retry-After metadata."); Assert(openAiClient.Contains("RetryConditionHeaderValue", StringComparison.Ordinal), "OpenAI retry should honour Retry-After metadata.");
} }
static void StoryIntelligenceGlobalProgressUsesWholeRunSceneTotals()
{
var root = PlotLineRoot();
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/StoryIntelligenceGlobalProgressViewModels.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceGlobalProgressService.cs"));
var hub = File.ReadAllText(Path.Combine(root, "Hubs/StoryIntelligenceHub.cs"));
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
var migration = File.ReadAllText(Path.Combine(root, "Sql/170_Phase26F_StoryIntelligenceWholeRunProgress.sql"));
var book = new StoryIntelligenceGlobalProgressBookViewModel
{
BookTitle = "Test Book",
PendingRunCount = 2,
RunningRunCount = 3,
TotalExpectedScenes = 47,
CompletedScenes = 5,
FailedScenes = 0,
ActiveScenes = 15,
TotalDurationMs = TimeSpan.FromMinutes(2).Ticks / TimeSpan.TicksPerMillisecond,
Status = StoryIntelligenceRunStatuses.Running
};
var model = new StoryIntelligenceGlobalProgressViewModel { Books = [book] };
Assert(model.TotalExpectedScenes == 47, "Global progress denominator should be the expected whole-run scene total.");
Assert(model.TotalDetectedScenes == 47, "Legacy detected-scenes accessor should mirror the expected scene total for display.");
Assert(model.CompletedScenes == 5, "Completed count should count successful completed scene analyses.");
Assert(model.ActiveScenes == 15, "Active scene count should remain distinct from the denominator.");
Assert(model.ProgressPercent == 11, "5 of 47 scenes should round to 11%, not 33% from a 15-scene active subset.");
Assert(book.SummaryText.Contains("5/47", StringComparison.Ordinal), "Book summary should display 5/47.");
var finalisingBook = new StoryIntelligenceGlobalProgressBookViewModel
{
BookTitle = "Test Book",
PendingRunCount = 0,
RunningRunCount = 1,
TotalExpectedScenes = 47,
CompletedScenes = 47,
FailedScenes = 0,
ActiveScenes = 0,
TotalDurationMs = TimeSpan.FromMinutes(8).Ticks / TimeSpan.TicksPerMillisecond,
Status = "Finalising"
};
Assert(finalisingBook.ProgressPercent == 100, "Scene progress can be visually full during finalisation.");
Assert(finalisingBook.EstimatedRemaining == "Finalising", "All scene analyses complete should show finalising until pipeline state is persisted.");
var failedBook = new StoryIntelligenceGlobalProgressBookViewModel
{
BookTitle = "Test Book",
PendingRunCount = 0,
RunningRunCount = 0,
TotalExpectedScenes = 47,
CompletedScenes = 46,
FailedScenes = 1,
ActiveScenes = 0,
TotalDurationMs = TimeSpan.FromMinutes(8).Ticks / TimeSpan.TicksPerMillisecond,
Status = StoryIntelligenceRunStatuses.CompletedWithWarnings
};
Assert(failedBook.ProgressPercent == 98, "46 success plus 1 failed must not display as 47 successful scenes.");
Assert(failedBook.EstimatedRemaining == "Finalising", "Terminal failures should be excluded from ETA while remaining visible.");
Assert(service.Contains("TotalExpectedScenes = summary.TotalExpectedScenes", StringComparison.Ordinal), "Global progress service should map total expected scenes.");
Assert(hub.Contains("WatchGlobalStoryIntelligenceProgress", StringComparison.Ordinal), "SignalR should expose the aggregate progress calculation.");
Assert(layout.Contains("TotalExpectedScenes", StringComparison.Ordinal), "Initial server-rendered strip should use total expected scenes.");
Assert(script.Contains("WatchGlobalStoryIntelligenceProgress", StringComparison.Ordinal), "SignalR updates should refresh aggregate progress instead of trusting chapter totals.");
Assert(!script.Contains("progress.totalDetectedScenes", StringComparison.Ordinal), "Client should not use per-run TotalDetectedScenes as the denominator.");
Assert(migration.Contains("CanonicalSceneScope", StringComparison.Ordinal), "Progress migration should derive expected scope from canonical scenes.");
Assert(migration.Contains("ActiveScenes", StringComparison.Ordinal), "Progress migration should keep active scene count distinct.");
Assert(migration.Contains("N'Finalising'", StringComparison.Ordinal), "Progress migration should expose a finalising state.");
}
static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume() static void StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume()
{ {
var root = PlotLineRoot(); var root = PlotLineRoot();

View File

@ -13,7 +13,8 @@ namespace PlotLine.Hubs;
public sealed class StoryIntelligenceHub( public sealed class StoryIntelligenceHub(
IStoryIntelligenceService storyIntelligence, IStoryIntelligenceService storyIntelligence,
IStoryIntelligenceResultRepository persistedRuns, IStoryIntelligenceResultRepository persistedRuns,
IOnboardingStoryIntelligenceService onboardingStoryIntelligence) : Hub IOnboardingStoryIntelligenceService onboardingStoryIntelligence,
IStoryIntelligenceGlobalProgressService globalProgress) : Hub
{ {
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
@ -58,6 +59,13 @@ public sealed class StoryIntelligenceHub(
return await onboardingStoryIntelligence.GetProgressAsync(batchId); return await onboardingStoryIntelligence.GetProgressAsync(batchId);
} }
public async Task<StoryIntelligenceGlobalProgressViewModel> WatchGlobalStoryIntelligenceProgress()
{
var userId = RequireUserId();
await Groups.AddToGroupAsync(Context.ConnectionId, UserGroup(userId));
return await globalProgress.GetForCurrentUserAsync();
}
public static string UserGroup(int userId) => $"story-intelligence:{userId}"; public static string UserGroup(int userId) => $"story-intelligence:{userId}";
private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, Scene? currentScene, string? latestSceneSummary) private static StoryIntelligenceRunProgressEvent ToRunProgress(StoryIntelligenceSavedRun run, string eventType, Scene? currentScene, string? latestSceneSummary)

View File

@ -354,9 +354,13 @@ public sealed class StoryIntelligenceActiveBookRunSummary
public int RunningRunCount { get; init; } public int RunningRunCount { get; init; }
public int CompletedRunCount { get; init; } public int CompletedRunCount { get; init; }
public int FailedRunCount { get; init; } public int FailedRunCount { get; init; }
public int TotalExpectedScenes { get; init; }
public int TotalDetectedScenes { get; init; } public int TotalDetectedScenes { get; init; }
public int CompletedScenes { get; init; } public int CompletedScenes { get; init; }
public int FailedScenes { get; init; } public int FailedScenes { get; init; }
public int ActiveScenes { get; init; }
public int ProgressPercent { get; init; }
public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending;
public long TotalDurationMs { get; init; } public long TotalDurationMs { get; init; }
public DateTime FirstCreatedUtc { get; init; } public DateTime FirstCreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; } public DateTime UpdatedUtc { get; init; }

View File

@ -32,9 +32,11 @@ public sealed class StoryIntelligenceGlobalProgressService(
RunningRunCount = summary.RunningRunCount, RunningRunCount = summary.RunningRunCount,
CompletedRunCount = summary.CompletedRunCount, CompletedRunCount = summary.CompletedRunCount,
FailedRunCount = summary.FailedRunCount, FailedRunCount = summary.FailedRunCount,
TotalDetectedScenes = summary.TotalDetectedScenes, TotalExpectedScenes = summary.TotalExpectedScenes,
CompletedScenes = summary.CompletedScenes, CompletedScenes = summary.CompletedScenes,
FailedScenes = summary.FailedScenes, FailedScenes = summary.FailedScenes,
ActiveScenes = summary.ActiveScenes,
Status = summary.Status,
TotalDurationMs = summary.TotalDurationMs, TotalDurationMs = summary.TotalDurationMs,
UpdatedUtc = summary.UpdatedUtc UpdatedUtc = summary.UpdatedUtc
}) })

View File

@ -0,0 +1,128 @@
CREATE OR ALTER PROCEDURE dbo.StoryIntelligenceRun_ListActiveBookSummaryForUser
@UserID int
AS
BEGIN
SET NOCOUNT ON;
;WITH ActiveScopes AS
(
SELECT
r.UserID,
r.ProjectID,
r.BookID,
LatestCreatedUtc = MAX(r.CreatedUtc)
FROM dbo.StoryIntelligenceRuns r
WHERE r.UserID = @UserID
AND r.Status IN (N'Pending', N'Running')
GROUP BY r.UserID, r.ProjectID, r.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
r.UserID,
r.ProjectID,
r.BookID,
s.SceneID
FROM ScopedRuns r
INNER JOIN dbo.Chapters c
ON c.ChapterID = r.ChapterID
AND c.IsArchived = 0
INNER JOIN dbo.Scenes s
ON s.ChapterID = c.ChapterID
AND s.IsArchived = 0
),
RunSummary AS
(
SELECT
r.UserID,
r.ProjectID,
r.BookID,
COUNT(1) 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 = 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(r.CreatedUtc) AS FirstCreatedUtc,
MAX(r.UpdatedUtc) AS UpdatedUtc
FROM ScopedRuns r
GROUP BY r.UserID, r.ProjectID, r.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
)
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,
summary.CompletedScenes,
summary.FailedScenes,
summary.ActiveScenes,
CASE
WHEN COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) <= 0 THEN 0
ELSE CONVERT(int, ROUND(summary.CompletedScenes * 100.0 / COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 1), 0))
END AS ProgressPercent,
CASE
WHEN summary.RunningRunCount > 0
AND COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0) > 0
AND summary.CompletedScenes + summary.FailedScenes >= COALESCE(expected.TotalExpectedScenes, NULLIF(summary.TotalDetectedScenes, 0), 0)
THEN N'Finalising'
WHEN summary.FailedRunCount > 0 OR summary.FailedScenes > 0 THEN N'CompletedWithWarnings'
WHEN summary.RunningRunCount > 0 THEN N'Running'
WHEN summary.PendingRunCount > 0 THEN N'Pending'
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 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
ORDER BY summary.UpdatedUtc DESC, summary.FirstCreatedUtc;
END;
GO

View File

@ -1,3 +1,5 @@
using PlotLine.Models;
namespace PlotLine.ViewModels; namespace PlotLine.ViewModels;
public sealed class StoryIntelligenceGlobalProgressViewModel public sealed class StoryIntelligenceGlobalProgressViewModel
@ -6,12 +8,21 @@ public sealed class StoryIntelligenceGlobalProgressViewModel
public bool HasActiveWork => Books.Any(book => book.HasActiveRuns); public bool HasActiveWork => Books.Any(book => book.HasActiveRuns);
public int ActiveBookCount => Books.Count(book => book.HasActiveRuns); public int ActiveBookCount => Books.Count(book => book.HasActiveRuns);
public int ActiveRunCount => Books.Sum(book => book.PendingRunCount + book.RunningRunCount); public int ActiveRunCount => Books.Sum(book => book.PendingRunCount + book.RunningRunCount);
public int TotalDetectedScenes => Books.Sum(book => book.TotalDetectedScenes); public int TotalExpectedScenes => Books.Sum(book => book.TotalExpectedScenes);
public int TotalDetectedScenes => TotalExpectedScenes;
public int CompletedScenes => Books.Sum(book => book.CompletedScenes); public int CompletedScenes => Books.Sum(book => book.CompletedScenes);
public int FailedScenes => Books.Sum(book => book.FailedScenes); public int FailedScenes => Books.Sum(book => book.FailedScenes);
public int ProgressPercent => TotalDetectedScenes <= 0 public int ActiveScenes => Books.Sum(book => book.ActiveScenes);
public int ProgressPercent => TotalExpectedScenes <= 0
? 0 ? 0
: Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalDetectedScenes)), 0, 100); : Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalExpectedScenes)), 0, 100);
public string Status => Books.Any(book => book.Status == "Finalising")
? "Finalising"
: Books.Any(book => book.HasFailures)
? StoryIntelligenceRunStatuses.CompletedWithWarnings
: Books.Any(book => book.HasActiveRuns)
? StoryIntelligenceRunStatuses.Running
: StoryIntelligenceRunStatuses.Completed;
public string SummaryText => ActiveBookCount switch public string SummaryText => ActiveBookCount switch
{ {
0 => string.Empty, 0 => string.Empty,
@ -30,34 +41,40 @@ public sealed class StoryIntelligenceGlobalProgressBookViewModel
public int RunningRunCount { get; init; } public int RunningRunCount { get; init; }
public int CompletedRunCount { get; init; } public int CompletedRunCount { get; init; }
public int FailedRunCount { get; init; } public int FailedRunCount { get; init; }
public int TotalDetectedScenes { get; init; } public int TotalExpectedScenes { get; init; }
public int TotalDetectedScenes => TotalExpectedScenes;
public int CompletedScenes { get; init; } public int CompletedScenes { get; init; }
public int FailedScenes { get; init; } public int FailedScenes { get; init; }
public int ActiveScenes { get; init; }
public string Status { get; init; } = StoryIntelligenceRunStatuses.Pending;
public long TotalDurationMs { get; init; } public long TotalDurationMs { get; init; }
public DateTime UpdatedUtc { get; init; } public DateTime UpdatedUtc { get; init; }
public bool HasActiveRuns => PendingRunCount > 0 || RunningRunCount > 0; public bool HasActiveRuns => PendingRunCount > 0 || RunningRunCount > 0;
public bool HasFailures => FailedRunCount > 0 || FailedScenes > 0; public bool HasFailures => FailedRunCount > 0 || FailedScenes > 0;
public int ProgressPercent => TotalDetectedScenes <= 0 public int ProgressPercent => TotalExpectedScenes <= 0
? 0 ? 0
: Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalDetectedScenes)), 0, 100); : Math.Clamp(Convert.ToInt32(Math.Round(CompletedScenes * 100m / TotalExpectedScenes)), 0, 100);
public string SummaryText => $"{BookTitle}: {CompletedScenes:N0}/{Math.Max(TotalDetectedScenes, CompletedScenes):N0} scenes"; public string SummaryText => $"{BookTitle}: {CompletedScenes:N0}/{Math.Max(TotalExpectedScenes, CompletedScenes):N0} scenes";
public string EstimatedRemaining => EstimateRemaining(TotalDurationMs, CompletedScenes, TotalDetectedScenes); public string EstimatedRemaining => Status == "Finalising"
? "Finalising"
: EstimateRemaining(TotalDurationMs, CompletedScenes, FailedScenes, TotalExpectedScenes);
private static string EstimateRemaining(long elapsedMs, int completedScenes, int totalScenes) private static string EstimateRemaining(long elapsedMs, int completedScenes, int failedScenes, int totalScenes)
{ {
if (totalScenes <= 0 || completedScenes <= 0) if (totalScenes <= 0 || completedScenes <= 0)
{ {
return "Calculating"; return "Calculating";
} }
if (completedScenes >= totalScenes) var remainingScenes = Math.Max(0, totalScenes - completedScenes - failedScenes);
if (remainingScenes == 0)
{ {
return "Finalising"; return "Finalising";
} }
var averageSceneMs = elapsedMs / Math.Max(1, completedScenes); var averageSceneMs = elapsedMs / Math.Max(1, completedScenes);
var remaining = TimeSpan.FromMilliseconds(averageSceneMs * (totalScenes - completedScenes)); var remaining = TimeSpan.FromMilliseconds(averageSceneMs * remainingScenes);
if (remaining.TotalMinutes >= 1) if (remaining.TotalMinutes >= 1)
{ {
return $"{Math.Max(1, (int)Math.Round(remaining.TotalMinutes)):N0} min"; return $"{Math.Max(1, (int)Math.Round(remaining.TotalMinutes)):N0} min";

View File

@ -234,6 +234,7 @@
<div class="story-intelligence-global-strip" <div class="story-intelligence-global-strip"
data-story-intelligence-global-strip data-story-intelligence-global-strip
data-progress-percent="@storyIntelligenceProgress.ProgressPercent" data-progress-percent="@storyIntelligenceProgress.ProgressPercent"
data-total-expected-scenes="@storyIntelligenceProgress.TotalExpectedScenes"
data-active-books="@storyIntelligenceProgress.ActiveBookCount" data-active-books="@storyIntelligenceProgress.ActiveBookCount"
data-book-id="@primaryBook?.BookID" data-book-id="@primaryBook?.BookID"
data-project-id="@primaryBook?.ProjectID" data-project-id="@primaryBook?.ProjectID"
@ -244,7 +245,11 @@
<span class="story-intelligence-global-strip__dot" aria-hidden="true"></span> <span class="story-intelligence-global-strip__dot" aria-hidden="true"></span>
<strong data-story-intelligence-global-title>@storyIntelligenceProgress.SummaryText</strong> <strong data-story-intelligence-global-title>@storyIntelligenceProgress.SummaryText</strong>
<span data-story-intelligence-global-detail> <span data-story-intelligence-global-detail>
@storyIntelligenceProgress.CompletedScenes.ToString("N0") of @storyIntelligenceProgress.TotalDetectedScenes.ToString("N0") scenes analysed @storyIntelligenceProgress.CompletedScenes.ToString("N0") of @storyIntelligenceProgress.TotalExpectedScenes.ToString("N0") scenes analysed
@if (storyIntelligenceProgress.FailedScenes > 0)
{
<text> &middot; @storyIntelligenceProgress.FailedScenes.ToString("N0") failed</text>
}
@if (primaryBook is not null) @if (primaryBook is not null)
{ {
<text> &middot; @primaryBook.EstimatedRemaining remaining</text> <text> &middot; @primaryBook.EstimatedRemaining remaining</text>

View File

@ -7,24 +7,28 @@
const title = strip.querySelector("[data-story-intelligence-global-title]"); const title = strip.querySelector("[data-story-intelligence-global-title]");
const detail = strip.querySelector("[data-story-intelligence-global-detail]"); const detail = strip.querySelector("[data-story-intelligence-global-detail]");
const bar = strip.querySelector("[data-story-intelligence-global-bar]"); const bar = strip.querySelector("[data-story-intelligence-global-bar]");
let refreshTimer = null;
const readNumber = (value) => { const readNumber = (value) => {
const parsed = Number.parseInt(value || "", 10); const parsed = Number.parseInt(value || "", 10);
return Number.isInteger(parsed) ? parsed : null; return Number.isInteger(parsed) ? parsed : null;
}; };
const trackedBookId = readNumber(strip.dataset.bookId);
const trackedProjectId = readNumber(strip.dataset.projectId);
const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0)); const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0));
const estimateRemaining = (elapsedMs, completed, total) => { const estimateRemaining = (elapsedMs, completed, failed, total, status) => {
if (status === "Finalising") {
return "Finalising";
}
if (!total || !completed) { if (!total || !completed) {
return "Calculating"; return "Calculating";
} }
if (completed >= total) { const remainingScenes = Math.max(0, total - completed - failed);
if (remainingScenes === 0) {
return "Finalising"; return "Finalising";
} }
const remainingMs = (elapsedMs / completed) * (total - completed); const remainingMs = (elapsedMs / completed) * remainingScenes;
const remainingSeconds = Math.max(1, Math.round(remainingMs / 1000)); const remainingSeconds = Math.max(1, Math.round(remainingMs / 1000));
if (remainingSeconds >= 60) { if (remainingSeconds >= 60) {
return `${Math.max(1, Math.round(remainingSeconds / 60))} min`; return `${Math.max(1, Math.round(remainingSeconds / 60))} min`;
@ -33,26 +37,22 @@
return `${remainingSeconds} sec`; return `${remainingSeconds} sec`;
}; };
const update = (progress) => { const render = (progress) => {
if (!progress) { const books = progress?.books || progress?.Books || [];
if (!books.length) {
strip.hidden = true;
return; return;
} }
const bookId = readNumber(progress.bookID ?? progress.bookId); strip.hidden = false;
const projectId = readNumber(progress.projectID ?? progress.projectId); const primary = books.find((book) => book.hasActiveRuns || book.HasActiveRuns) || books[0];
if (trackedBookId && bookId && trackedBookId !== bookId) { const activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || books.length;
return; const status = progress.status || progress.Status || primary.status || primary.Status || "";
} const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes) || 0;
if (!trackedBookId && trackedProjectId && projectId && trackedProjectId !== projectId) {
return;
}
const status = progress.status || progress.Status || "";
const total = readNumber(progress.totalDetectedScenes ?? progress.TotalDetectedScenes) || 0;
const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0; const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0;
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0; const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0;
const elapsedMs = readNumber(progress.totalDurationMs ?? progress.TotalDurationMs) || 0; const active = readNumber(progress.activeScenes ?? progress.ActiveScenes) || 0;
const elapsedMs = readNumber(primary.totalDurationMs ?? primary.TotalDurationMs) || 0;
const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0; const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((completed / total) * 100))) : 0;
if (bar) { if (bar) {
@ -60,12 +60,16 @@
} }
if (detail) { if (detail) {
const remaining = estimateRemaining(elapsedMs, completed, total); const remaining = estimateRemaining(elapsedMs, completed, failed, total, status);
detail.textContent = `${formatCount(completed)} of ${formatCount(total)} scenes analysed \u00b7 ${remaining} remaining`; 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 (title && status) {
if (status === "Failed") { if (status === "Finalising") {
title.textContent = "Finalising Story Intelligence";
} else if (status === "Failed") {
title.textContent = "Story Intelligence needs attention"; title.textContent = "Story Intelligence needs attention";
} else if (status === "Cancelled") { } else if (status === "Cancelled") {
title.textContent = "Story Intelligence cancelled"; title.textContent = "Story Intelligence cancelled";
@ -74,17 +78,36 @@
window.setTimeout(() => { window.setTimeout(() => {
strip.hidden = true; strip.hidden = true;
}, 10000); }, 10000);
} else if (activeBookCount > 1) {
title.textContent = `${formatCount(activeBookCount)} books analysing`;
} else {
title.textContent = primary.summaryText || primary.SummaryText || title.textContent;
} }
} }
}; };
const refresh = () => {
connection.invoke("WatchGlobalStoryIntelligenceProgress")
.then(render)
.catch(() => {
strip.dataset.connection = "offline";
});
};
const scheduleRefresh = () => {
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(refresh, 350);
};
const connection = new signalR.HubConnectionBuilder() const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/story-intelligence") .withUrl("/hubs/story-intelligence")
.withAutomaticReconnect() .withAutomaticReconnect()
.build(); .build();
connection.on("StoryIntelligenceRunProgressChanged", update); connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh);
connection.start().catch(() => { connection.start()
.then(refresh)
.catch(() => {
strip.dataset.connection = "offline"; strip.dataset.connection = "offline";
}); });
})(); })();