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 heavy review modules offer bounded rendering", StoryIntelligenceHeavyReviewModulesOfferBoundedRendering),
("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)
@ -270,6 +271,77 @@ static void StoryIntelligenceJob6UsesBoundedConcurrentChapterExecution()
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()
{
var root = PlotLineRoot();

View File

@ -13,7 +13,8 @@ namespace PlotLine.Hubs;
public sealed class StoryIntelligenceHub(
IStoryIntelligenceService storyIntelligence,
IStoryIntelligenceResultRepository persistedRuns,
IOnboardingStoryIntelligenceService onboardingStoryIntelligence) : Hub
IOnboardingStoryIntelligenceService onboardingStoryIntelligence,
IStoryIntelligenceGlobalProgressService globalProgress) : Hub
{
public override async Task OnConnectedAsync()
{
@ -58,6 +59,13 @@ public sealed class StoryIntelligenceHub(
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}";
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 CompletedRunCount { get; init; }
public int FailedRunCount { get; init; }
public int TotalExpectedScenes { get; init; }
public int TotalDetectedScenes { get; init; }
public int CompletedScenes { 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 DateTime FirstCreatedUtc { get; init; }
public DateTime UpdatedUtc { get; init; }

View File

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

View File

@ -234,6 +234,7 @@
<div class="story-intelligence-global-strip"
data-story-intelligence-global-strip
data-progress-percent="@storyIntelligenceProgress.ProgressPercent"
data-total-expected-scenes="@storyIntelligenceProgress.TotalExpectedScenes"
data-active-books="@storyIntelligenceProgress.ActiveBookCount"
data-book-id="@primaryBook?.BookID"
data-project-id="@primaryBook?.ProjectID"
@ -244,7 +245,11 @@
<span class="story-intelligence-global-strip__dot" aria-hidden="true"></span>
<strong data-story-intelligence-global-title>@storyIntelligenceProgress.SummaryText</strong>
<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)
{
<text> &middot; @primaryBook.EstimatedRemaining remaining</text>

View File

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