Recent discoveries
+
Possible characters, locations and story clues will appear here as PlotDirector reads.
@foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber))
{
-
+
@FriendlyStatus(chapter.Status)
@((chapter.CompletedScenes ?? 0).ToString("N0"))
@((chapter.TotalDetectedScenes ?? 0).ToString("N0"))
@@ -112,11 +117,13 @@
-
Review analysis
+ @if (!Model.HasActiveRuns && Model.Chapters.Any(chapter => chapter.IsRunComplete || chapter.IsFailed || chapter.HasCompletedCommit))
+ {
+
Review analysis
+ }
}
@@ -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)
{
diff --git a/PlotLine/wwwroot/css/onboarding.css b/PlotLine/wwwroot/css/onboarding.css
index 517c104..c5322ab 100644
--- a/PlotLine/wwwroot/css/onboarding.css
+++ b/PlotLine/wwwroot/css/onboarding.css
@@ -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;
diff --git a/PlotLine/wwwroot/js/story-intelligence-progress.js b/PlotLine/wwwroot/js/story-intelligence-progress.js
index 383d918..030f4af 100644
--- a/PlotLine/wwwroot/js/story-intelligence-progress.js
+++ b/PlotLine/wwwroot/js/story-intelligence-progress.js
@@ -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;