Phase 20AF – Story Intelligence Progress Page Layout and Estimate Fix
This commit is contained in:
parent
2c4849a302
commit
766ad28325
@ -230,6 +230,7 @@ public sealed class OnboardingStoryIntelligenceService(
|
||||
TotalDetectedScenes = run.TotalDetectedScenes,
|
||||
CompletedScenes = run.CompletedScenes,
|
||||
FailedScenes = run.FailedScenes,
|
||||
SourceWordCount = run.SourceWordCount,
|
||||
TotalTokens = run.TotalTokens,
|
||||
TotalDurationMs = run.TotalDurationMs,
|
||||
FailureStage = run.FailureStage,
|
||||
|
||||
@ -166,6 +166,7 @@ public sealed class StoryIntelligenceOnboardingChapterViewModel
|
||||
public int? TotalDetectedScenes { get; init; }
|
||||
public int? CompletedScenes { get; init; }
|
||||
public int? FailedScenes { get; init; }
|
||||
public int? SourceWordCount { get; init; }
|
||||
public int? TotalTokens { get; init; }
|
||||
public long? TotalDurationMs { get; init; }
|
||||
public string? FailureStage { get; init; }
|
||||
|
||||
@ -46,23 +46,23 @@
|
||||
<div class="story-live-stats">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<span>Elapsed</span>
|
||||
<strong data-story-elapsed>@Elapsed(Model)</strong>
|
||||
<strong class="story-stat-value" data-story-elapsed>@Elapsed(Model)</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Estimated remaining</span>
|
||||
<strong data-story-remaining>@EstimateRemaining(Model)</strong>
|
||||
<strong class="story-stat-value" data-story-remaining>@EstimateRemaining(Model)</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -85,8 +85,9 @@
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<div class="story-live-discoveries">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
@ -94,7 +95,11 @@
|
||||
<div class="story-run-state" aria-hidden="true" hidden>
|
||||
@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-completed>@((chapter.CompletedScenes ?? 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" />
|
||||
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button>
|
||||
</form>
|
||||
<a class="btn btn-primary @(Model.HasActiveRuns ? "disabled" : string.Empty)"
|
||||
@if (!Model.HasActiveRuns && Model.Chapters.Any(chapter => chapter.IsRunComplete || chapter.IsFailed || chapter.HasCompletedCommit))
|
||||
{
|
||||
<a class="btn btn-primary"
|
||||
data-story-review-link
|
||||
asp-action="StoryIntelligenceReview"
|
||||
asp-route-batchId="@Model.BatchID"
|
||||
aria-disabled="@Model.HasActiveRuns">Review analysis</a>
|
||||
asp-route-batchId="@Model.BatchID">Review analysis</a>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@ -245,7 +252,60 @@
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@ -613,7 +613,7 @@
|
||||
}
|
||||
|
||||
.story-live-hero p:not(.eyebrow) {
|
||||
max-width: 780px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.story-live-progress > .alert {
|
||||
@ -710,6 +710,14 @@
|
||||
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-feed-grid {
|
||||
display: grid;
|
||||
@ -754,6 +762,16 @@
|
||||
background: rgba(47, 111, 99, .08);
|
||||
}
|
||||
|
||||
.story-live-discoveries {
|
||||
display: grid;
|
||||
gap: .45rem;
|
||||
}
|
||||
|
||||
.story-discovery-placeholder {
|
||||
margin: 0;
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
.story-review-page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
||||
@ -70,21 +70,55 @@
|
||||
}
|
||||
};
|
||||
|
||||
const friendlyRemaining = (value, completedScenes, totalScenes) => {
|
||||
if (totalScenes > 0 && completedScenes >= totalScenes) {
|
||||
return "Almost done";
|
||||
}
|
||||
|
||||
if (!value || value === "Calculating") {
|
||||
return "Estimating...";
|
||||
}
|
||||
|
||||
const minutes = Number.parseInt(String(value).replace(/[^0-9]/g, ""), 10);
|
||||
const friendlyRemainingFromSeconds = (seconds) => {
|
||||
const minutes = Math.ceil(seconds / 60);
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) {
|
||||
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 || "")
|
||||
@ -200,6 +234,10 @@
|
||||
const item = document.createElement("li");
|
||||
item.textContent = possibleDiscoveryText(text);
|
||||
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) {
|
||||
list.lastElementChild?.remove();
|
||||
}
|
||||
@ -265,6 +303,9 @@
|
||||
return status === "Pending" || status === "Running";
|
||||
});
|
||||
root.dataset.storyIntelligenceHasActiveRuns = String(active);
|
||||
root.querySelectorAll("[data-story-remaining]").forEach((node) => {
|
||||
node.textContent = calculateRemaining(chapters, active);
|
||||
});
|
||||
|
||||
if (hadActiveRuns && !active && root.dataset.reloadScheduled !== "true") {
|
||||
root.dataset.reloadScheduled = "true";
|
||||
@ -291,8 +332,8 @@
|
||||
const total = field(state, "totalDetectedScenes", "TotalDetectedScenes");
|
||||
const failed = field(state, "failedScenes", "FailedScenes");
|
||||
const tokens = field(state, "totalTokens", "TotalTokens");
|
||||
const totalDurationMs = field(state, "totalDurationMs", "TotalDurationMs");
|
||||
const elapsed = field(state, "elapsedTime", "ElapsedTime") || "0 sec";
|
||||
const remaining = field(state, "estimatedRemaining", "EstimatedRemaining") || "Calculating";
|
||||
const currentSceneNumber = field(state, "currentSceneNumber", "CurrentSceneNumber");
|
||||
const displayMessage = friendlyMessage(message, stage, status, currentSceneNumber);
|
||||
const latestSummary = field(state, "latestSceneSummary", "LatestSceneSummary");
|
||||
@ -303,6 +344,9 @@
|
||||
node.textContent = statusLabel(state);
|
||||
});
|
||||
chapter.dataset.storyRawStatus = status;
|
||||
if (totalDurationMs !== null) {
|
||||
chapter.dataset.storyDurationMs = String(totalDurationMs);
|
||||
}
|
||||
chapter.querySelectorAll("[data-story-run-status]").forEach((node) => {
|
||||
node.textContent = friendlyStatus(status);
|
||||
});
|
||||
@ -358,10 +402,6 @@
|
||||
root.querySelectorAll("[data-story-elapsed]").forEach((node) => {
|
||||
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]");
|
||||
if (summaryNode && latestSummary) {
|
||||
summaryNode.textContent = latestSummary;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user