Fix Story Intelligence global progress totals
This commit is contained in:
parent
738c1d118f
commit
390ea19550
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user