Phase 20AF – Story Intelligence Progress Page Layout and Estimate Fix

This commit is contained in:
Nick Beckley 2026-07-06 21:47:59 +01:00
parent 2c4849a302
commit 766ad28325
5 changed files with 150 additions and 30 deletions

View File

@ -230,6 +230,7 @@ public sealed class OnboardingStoryIntelligenceService(
TotalDetectedScenes = run.TotalDetectedScenes, TotalDetectedScenes = run.TotalDetectedScenes,
CompletedScenes = run.CompletedScenes, CompletedScenes = run.CompletedScenes,
FailedScenes = run.FailedScenes, FailedScenes = run.FailedScenes,
SourceWordCount = run.SourceWordCount,
TotalTokens = run.TotalTokens, TotalTokens = run.TotalTokens,
TotalDurationMs = run.TotalDurationMs, TotalDurationMs = run.TotalDurationMs,
FailureStage = run.FailureStage, FailureStage = run.FailureStage,

View File

@ -166,6 +166,7 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel
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? SourceWordCount { get; init; }
public int? TotalTokens { get; init; } public int? TotalTokens { get; init; }
public long? TotalDurationMs { get; init; } public long? TotalDurationMs { get; init; }
public string? FailureStage { get; init; } public string? FailureStage { get; init; }

View File

@ -46,23 +46,23 @@
<div class="story-live-stats"> <div class="story-live-stats">
<div> <div>
<span>Chapters read</span> <span>Chapters read</span>
<strong><span data-story-chapters-completed>@Model.CompletedChapterCount</span> of @Model.ChapterCount</strong> <strong class="story-stat-value"><span data-story-chapters-completed>@Model.CompletedChapterCount</span> of @Model.ChapterCount</strong>
</div> </div>
<div> <div>
<span>Scenes read</span> <span>Scenes read</span>
<strong><span data-story-scenes-completed>@Model.TotalCompletedScenes</span> so far</strong> <strong class="story-stat-value"><span data-story-scenes-completed>@Model.TotalCompletedScenes</span> so far</strong>
</div> </div>
<div> <div>
<span>Needs review</span> <span>Needs review</span>
<strong data-story-failures>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong> <strong class="story-stat-value" data-story-failures>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong>
</div> </div>
<div> <div>
<span>Elapsed</span> <span>Elapsed</span>
<strong data-story-elapsed>@Elapsed(Model)</strong> <strong class="story-stat-value" data-story-elapsed>@Elapsed(Model)</strong>
</div> </div>
<div> <div>
<span>Estimated remaining</span> <span>Estimated remaining</span>
<strong data-story-remaining>@EstimateRemaining(Model)</strong> <strong class="story-stat-value" data-story-remaining>@EstimateRemaining(Model)</strong>
</div> </div>
</div> </div>
</section> </section>
@ -85,8 +85,9 @@
<p class="lead story-latest-summary" data-story-latest-summary hidden></p> <p class="lead story-latest-summary" data-story-latest-summary hidden></p>
<p class="text-muted" data-story-latest-empty>Scene summaries will appear here as PlotDirector reads.</p> <p class="text-muted" data-story-latest-empty>Scene summaries will appear here as PlotDirector reads.</p>
</div> </div>
<div> <div class="story-live-discoveries">
<h2>Recent discoveries</h2> <h2>Recent discoveries</h2>
<p class="text-muted story-discovery-placeholder" data-story-discovery-empty>Possible characters, locations and story clues will appear here as PlotDirector reads.</p>
<ul data-story-discovery-feed></ul> <ul data-story-discovery-feed></ul>
</div> </div>
</section> </section>
@ -94,7 +95,11 @@
<div class="story-run-state" aria-hidden="true" hidden> <div class="story-run-state" aria-hidden="true" hidden>
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber)) @foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
{ {
<div data-story-run-id="@chapter.RunID" data-story-chapter-title="@chapter.ChapterTitle" data-story-raw-status="@chapter.Status"> <div data-story-run-id="@chapter.RunID"
data-story-chapter-title="@chapter.ChapterTitle"
data-story-raw-status="@chapter.Status"
data-story-source-word-count="@(chapter.SourceWordCount ?? 0)"
data-story-duration-ms="@(chapter.TotalDurationMs ?? 0)">
<span data-story-run-status>@FriendlyStatus(chapter.Status)</span> <span data-story-run-status>@FriendlyStatus(chapter.Status)</span>
<span data-story-run-completed>@((chapter.CompletedScenes ?? 0).ToString("N0"))</span> <span data-story-run-completed>@((chapter.CompletedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-total>@((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</span> <span data-story-run-total>@((chapter.TotalDetectedScenes ?? 0).ToString("N0"))</span>
@ -112,11 +117,13 @@
<input type="hidden" name="batchId" value="@Model.BatchID" /> <input type="hidden" name="batchId" value="@Model.BatchID" />
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button> <button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button>
</form> </form>
<a class="btn btn-primary @(Model.HasActiveRuns ? "disabled" : string.Empty)" @if (!Model.HasActiveRuns && Model.Chapters.Any(chapter => chapter.IsRunComplete || chapter.IsFailed || chapter.HasCompletedCommit))
data-story-review-link {
asp-action="StoryIntelligenceReview" <a class="btn btn-primary"
asp-route-batchId="@Model.BatchID" data-story-review-link
aria-disabled="@Model.HasActiveRuns">Review analysis</a> asp-action="StoryIntelligenceReview"
asp-route-batchId="@Model.BatchID">Review analysis</a>
}
} }
</div> </div>
</div> </div>
@ -245,7 +252,60 @@
} }
private static string EstimateRemaining(StoryIntelligenceProgressViewModel model) private static string EstimateRemaining(StoryIntelligenceProgressViewModel model)
=> model.HasActiveRuns && model.TotalCompletedScenes > 0 ? "Estimating..." : model.HasActiveRuns ? "Estimating..." : "Ready to review"; {
if (!model.HasActiveRuns)
{
return "Ready to review";
}
var completed = model.Chapters
.Where(chapter => chapter.IsRunComplete && (chapter.SourceWordCount ?? 0) > 0 && (chapter.TotalDurationMs ?? 0) > 0)
.ToList();
if (completed.Count == 0)
{
return "Estimating...";
}
var completedWords = completed.Sum(chapter => chapter.SourceWordCount ?? 0);
var completedSeconds = completed.Sum(chapter => chapter.TotalDurationMs ?? 0) / 1000m;
var remainingWords = model.Chapters
.Where(chapter => !chapter.IsRunComplete && !chapter.IsFailed)
.Sum(chapter => chapter.SourceWordCount ?? 0);
if (completedWords <= 0 || completedSeconds <= 0 || remainingWords <= 0)
{
return "Estimating...";
}
var secondsPerWord = completedSeconds / completedWords;
var bufferedSeconds = secondsPerWord * remainingWords * 1.35m;
return FriendlyRemaining(bufferedSeconds);
}
private static string FriendlyRemaining(decimal seconds)
{
var minutes = (int)Math.Ceiling(seconds / 60m);
if (minutes <= 0)
{
return "Estimating...";
}
if (minutes <= 20)
{
return "About 20-30 minutes";
}
if (minutes <= 45)
{
return "About 45-60 minutes";
}
if (minutes <= 120)
{
return "About 1-2 hours";
}
return "More than 2 hours";
}
private static string CurrentScene(StoryIntelligenceProgressViewModel model) private static string CurrentScene(StoryIntelligenceProgressViewModel model)
{ {

View File

@ -613,7 +613,7 @@
} }
.story-live-hero p:not(.eyebrow) { .story-live-hero p:not(.eyebrow) {
max-width: 780px; max-width: none;
} }
.story-live-progress > .alert { .story-live-progress > .alert {
@ -710,6 +710,14 @@
white-space: nowrap; white-space: nowrap;
} }
.story-stat-value {
display: inline-flex;
align-items: baseline;
gap: .25rem;
max-width: 100%;
white-space: nowrap;
}
.story-live-current, .story-live-current,
.story-live-feed-grid { .story-live-feed-grid {
display: grid; display: grid;
@ -754,6 +762,16 @@
background: rgba(47, 111, 99, .08); background: rgba(47, 111, 99, .08);
} }
.story-live-discoveries {
display: grid;
gap: .45rem;
}
.story-discovery-placeholder {
margin: 0;
font-size: .95rem;
}
.story-review-page { .story-review-page {
display: grid; display: grid;
gap: 1rem; gap: 1rem;

View File

@ -70,21 +70,55 @@
} }
}; };
const friendlyRemaining = (value, completedScenes, totalScenes) => { const friendlyRemainingFromSeconds = (seconds) => {
if (totalScenes > 0 && completedScenes >= totalScenes) { const minutes = Math.ceil(seconds / 60);
return "Almost done";
}
if (!value || value === "Calculating") {
return "Estimating...";
}
const minutes = Number.parseInt(String(value).replace(/[^0-9]/g, ""), 10);
if (!Number.isFinite(minutes) || minutes <= 0) { if (!Number.isFinite(minutes) || minutes <= 0) {
return "Estimating..."; return "Estimating...";
} }
return `About ${minutes}-${Math.max(minutes + 1, minutes * 2)} minutes`; if (minutes <= 20) {
return "About 20-30 minutes";
}
if (minutes <= 45) {
return "About 45-60 minutes";
}
if (minutes <= 120) {
return "About 1-2 hours";
}
return "More than 2 hours";
};
const calculateRemaining = (chapters, hasActiveRuns) => {
if (!hasActiveRuns) {
return "Ready to review";
}
const completed = chapters.filter((chapter) => {
const status = chapter.dataset.storyRawStatus || "";
const words = Number.parseInt(chapter.dataset.storySourceWordCount || "0", 10) || 0;
const duration = Number.parseInt(chapter.dataset.storyDurationMs || "0", 10) || 0;
return (status === "Completed" || status === "CompletedWithWarnings") && words > 0 && duration > 0;
});
if (completed.length === 0) {
return "Estimating...";
}
const completedWords = completed.reduce((total, chapter) => total + (Number.parseInt(chapter.dataset.storySourceWordCount || "0", 10) || 0), 0);
const completedSeconds = completed.reduce((total, chapter) => total + ((Number.parseInt(chapter.dataset.storyDurationMs || "0", 10) || 0) / 1000), 0);
const remainingWords = chapters.reduce((total, chapter) => {
const status = chapter.dataset.storyRawStatus || "";
if (status === "Completed" || status === "CompletedWithWarnings" || status === "Failed" || status === "Cancelled") {
return total;
}
return total + (Number.parseInt(chapter.dataset.storySourceWordCount || "0", 10) || 0);
}, 0);
if (completedWords <= 0 || completedSeconds <= 0 || remainingWords <= 0) {
return "Estimating...";
}
return friendlyRemainingFromSeconds((completedSeconds / completedWords) * remainingWords * 1.35);
}; };
const possibleDiscoveryText = (text) => String(text || "") const possibleDiscoveryText = (text) => String(text || "")
@ -200,6 +234,10 @@
const item = document.createElement("li"); const item = document.createElement("li");
item.textContent = possibleDiscoveryText(text); item.textContent = possibleDiscoveryText(text);
list.prepend(item); list.prepend(item);
const placeholder = list.closest(".story-live-discoveries")?.querySelector("[data-story-discovery-empty]");
if (placeholder) {
placeholder.hidden = true;
}
while (list.children.length > 8) { while (list.children.length > 8) {
list.lastElementChild?.remove(); list.lastElementChild?.remove();
} }
@ -265,6 +303,9 @@
return status === "Pending" || status === "Running"; return status === "Pending" || status === "Running";
}); });
root.dataset.storyIntelligenceHasActiveRuns = String(active); root.dataset.storyIntelligenceHasActiveRuns = String(active);
root.querySelectorAll("[data-story-remaining]").forEach((node) => {
node.textContent = calculateRemaining(chapters, active);
});
if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") { if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") {
root.dataset.reloadScheduled = "true"; root.dataset.reloadScheduled = "true";
@ -291,8 +332,8 @@
const total = field(state, "totalDetectedScenes", "TotalDetectedScenes"); const total = field(state, "totalDetectedScenes", "TotalDetectedScenes");
const failed = field(state, "failedScenes", "FailedScenes"); const failed = field(state, "failedScenes", "FailedScenes");
const tokens = field(state, "totalTokens", "TotalTokens"); const tokens = field(state, "totalTokens", "TotalTokens");
const totalDurationMs = field(state, "totalDurationMs", "TotalDurationMs");
const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec"; const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec";
const remaining = field(state, "estimatedRemaining", "EstimatedRemaining") || "Calculating";
const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber"); const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber");
const displayMessage = friendlyMessage(message, stage, status, currentSceneNumber); const displayMessage = friendlyMessage(message, stage, status, currentSceneNumber);
const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary"); const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary");
@ -303,6 +344,9 @@
node.textContent = statusLabel(state); node.textContent = statusLabel(state);
}); });
chapter.dataset.storyRawStatus = status; chapter.dataset.storyRawStatus = status;
if (totalDurationMs !== null) {
chapter.dataset.storyDurationMs = String(totalDurationMs);
}
chapter.querySelectorAll("[data-story-run-status]").forEach((node) => { chapter.querySelectorAll("[data-story-run-status]").forEach((node) => {
node.textContent = friendlyStatus(status); node.textContent = friendlyStatus(status);
}); });
@ -358,10 +402,6 @@
root.querySelectorAll("[data-story-elapsed]").forEach((node) => { root.querySelectorAll("[data-story-elapsed]").forEach((node) => {
node.textContent = elapsed; node.textContent = elapsed;
}); });
root.querySelectorAll("[data-story-remaining]").forEach((node) => {
node.textContent = friendlyRemaining(remaining, Number.parseInt(completed || "0", 10) || 0, Number.parseInt(total || "0", 10) || 0);
});
const summaryNode = root.querySelector("[data-story-latest-summary]"); const summaryNode = root.querySelector("[data-story-latest-summary]");
if (summaryNode && latestSummary) { if (summaryNode && latestSummary) {
summaryNode.textContent = latestSummary; summaryNode.textContent = latestSummary;