PlotDirector/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml

357 lines
15 KiB
Plaintext

@model StoryIntelligenceProgressViewModel
@{
ViewData["Title"] = "Reading your manuscript";
var runIds = string.Join(",", Model.Chapters.Select(chapter => chapter.RunID));
var progressPercent = ProgressPercent(Model);
var currentChapter = CurrentChapter(Model);
}
<section class="onboarding-shell" aria-labelledby="story-progress-title">
<div class="onboarding-panel onboarding-review-panel story-live-progress"
data-story-intelligence-onboarding
data-story-intelligence-batch-id="@Model.BatchID"
data-story-intelligence-run-ids="@runIds"
data-story-max-progress-percent="@progressPercent"
data-story-intelligence-has-active-runs="@Model.HasActiveRuns.ToString().ToLowerInvariant()">
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = 8 })" />
<div class="onboarding-copy story-live-hero">
<p class="eyebrow">Step 8 of 9</p>
<h1 id="story-progress-title">Reading your manuscript</h1>
<p>PlotDirector is analysing your chapters and preparing scenes for review. You can safely leave this page and return later.</p>
</div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
{
<div class="alert alert-danger">@error</div>
}
@if (TempData["OnboardingStoryIntelligenceMessage"] is string message)
{
<div class="alert alert-success">@message</div>
}
<section class="alert alert-info">
You can close Word or this browser tab once analysis has started. PlotDirector is working from a saved manuscript snapshot.
</section>
<section class="story-live-progress-card" aria-label="Import progress">
<div class="story-live-progress-heading">
<div>
<p class="eyebrow mb-1">Import progress</p>
<strong data-story-overall-percent>@progressPercent%</strong>
</div>
<span data-story-current-stage>@FriendlyStage(currentChapter?.CurrentStage, currentChapter?.Status)</span>
</div>
<div class="progress" role="progressbar" aria-label="Story Intelligence progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="@progressPercent">
<div class="progress-bar" data-story-overall-progress-bar style="width:@progressPercent%"></div>
</div>
<div class="story-live-stats">
<div>
<span>Chapters read</span>
<strong class="story-stat-value" data-story-chapters-read-value>@Model.CompletedChapterCount of @Model.ChapterCount</strong>
</div>
<div>
<span>Scenes read</span>
<strong class="story-stat-value" data-story-scenes-read-value>@Model.TotalCompletedScenes so far</strong>
</div>
<div>
<span>Needs review</span>
<strong class="story-stat-value" data-story-failures>@(Model.FailedChapterCount + Model.TotalFailedScenes)</strong>
</div>
<div>
<span>Elapsed</span>
<strong class="story-stat-value" data-story-elapsed>@Elapsed(Model)</strong>
</div>
<div>
<span>Estimated remaining</span>
<strong class="story-stat-value" data-story-remaining>@EstimateRemaining(Model)</strong>
</div>
</div>
</section>
<section class="story-live-current" aria-label="Current activity">
<div>
<p class="eyebrow">Current chapter</p>
<h2 data-story-current-chapter>@(currentChapter?.ChapterTitle ?? "Waiting")</h2>
<p data-story-current-message>@FriendlyMessage(currentChapter)</p>
</div>
<div class="story-current-scene-panel">
<p class="eyebrow">Current scene</p>
<strong data-story-current-scene>@CurrentScene(Model)</strong>
<div class="story-chapter-progress">
<span data-story-chapter-progress-text>@ChapterProgressText(currentChapter)</span>
<div class="progress" role="progressbar" aria-label="Current chapter progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="@ChapterProgressPercent(currentChapter)">
<div class="progress-bar" data-story-chapter-progress-bar style="width:@ChapterProgressPercent(currentChapter)%"></div>
</div>
</div>
</div>
</section>
<section class="story-live-feed-grid" aria-label="Live manuscript feed">
<div>
<h2>Latest from your manuscript</h2>
@if (!string.IsNullOrWhiteSpace(currentChapter?.LatestSceneSummary))
{
<p class="lead story-latest-summary" data-story-latest-summary>@currentChapter.LatestSceneSummary</p>
<p class="text-muted" data-story-latest-empty hidden>Scene summaries will appear here as PlotDirector reads.</p>
}
else
{
<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 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>
<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"
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>
<span data-story-run-failed>@((chapter.FailedScenes ?? 0).ToString("N0"))</span>
<span data-story-run-message>@FriendlyMessage(chapter)</span>
<span data-story-run-error>@(chapter.ErrorMessage ?? string.Empty)</span>
<span data-story-run-latest-summary>@(chapter.LatestSceneSummary ?? string.Empty)</span>
</div>
}
</div>
<div class="onboarding-actions">
@if (Model.BatchID.HasValue)
{
<form asp-action="CancelStoryIntelligence" method="post">
<input type="hidden" name="batchId" value="@Model.BatchID" />
<button class="btn btn-outline-secondary" type="submit" disabled="@(!Model.HasActiveRuns)">Cancel analysis</button>
</form>
@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">Review analysis</a>
}
}
</div>
</div>
</section>
@section Scripts {
<script src="~/js/story-intelligence-progress.js" asp-append-version="true"></script>
}
@functions {
private static string FriendlyStatus(string? status)
=> status switch
{
StoryIntelligenceRunStatuses.Pending => "Waiting",
StoryIntelligenceRunStatuses.Running => "Reading",
StoryIntelligenceRunStatuses.Completed => "Ready to create",
StoryIntelligenceRunStatuses.CompletedWithWarnings => "Ready with notes",
StoryIntelligenceRunStatuses.Failed => "Needs review",
StoryIntelligenceRunStatuses.Cancelled => "Cancelled",
_ => "Waiting"
};
private static string FriendlyStage(string? stage, string? status)
{
if (status is StoryIntelligenceRunStatuses.Completed or StoryIntelligenceRunStatuses.CompletedWithWarnings)
{
return "Ready to review";
}
if (status is StoryIntelligenceRunStatuses.Failed)
{
return "Needs review";
}
if (status is StoryIntelligenceRunStatuses.Cancelled)
{
return "Cancelled";
}
return stage switch
{
"Queued" => "Waiting",
StoryIntelligenceFailureStages.ChapterStructure => "Finding scenes",
StoryIntelligenceFailureStages.SceneIntelligence => "Reading scenes",
StoryIntelligenceFailureStages.SceneSplit => "Preparing review",
StoryIntelligenceFailureStages.Validation => "Preparing review",
StoryIntelligenceFailureStages.Persistence => "Preparing review",
_ => "Reading"
};
}
private static string FriendlyMessage(StoryIntelligenceOnboardingChapterViewModel? chapter)
{
if (chapter is null)
{
return "Waiting for analysis to begin.";
}
if (chapter.Status == StoryIntelligenceRunStatuses.Cancelled)
{
return "Analysis was cancelled. Any completed chapters remain available for review.";
}
if (chapter.Status == StoryIntelligenceRunStatuses.Failed)
{
return "This chapter needs review before scenes can be created.";
}
return chapter.CurrentStage switch
{
"Queued" => "Waiting to read this chapter.",
StoryIntelligenceFailureStages.ChapterStructure => "Finding scenes in this chapter.",
StoryIntelligenceFailureStages.SceneSplit => "Preparing the detected scenes.",
StoryIntelligenceFailureStages.SceneIntelligence => "Reading the detected scenes.",
StoryIntelligenceFailureStages.Validation => "Checking the results.",
_ => chapter.IsRunComplete
? "This chapter is ready to review."
: "Waiting for analysis to begin."
};
}
private static StoryIntelligenceOnboardingChapterViewModel? CurrentChapter(StoryIntelligenceProgressViewModel model)
=> model.Chapters.FirstOrDefault(chapter => chapter.IsActive)
?? model.Chapters.LastOrDefault(chapter => chapter.IsRunComplete)
?? model.Chapters.FirstOrDefault();
private static int ProgressPercent(StoryIntelligenceProgressViewModel model)
{
if (model.ChapterCount == 0)
{
return 0;
}
var chapterProgress = model.Chapters.Sum(ChapterProgress);
return Math.Clamp((int)Math.Floor(chapterProgress * 100m / model.ChapterCount), 0, 100);
}
private static decimal ChapterProgress(StoryIntelligenceOnboardingChapterViewModel chapter)
{
if (chapter.HasCompletedCommit || chapter.IsRunComplete || chapter.IsFailed)
{
return 1m;
}
if (chapter.Status == StoryIntelligenceRunStatuses.Pending)
{
return 0m;
}
if ((chapter.TotalDetectedScenes ?? 0) > 0)
{
var sceneProgress = Math.Clamp((chapter.CompletedScenes ?? 0) / Math.Max(1m, chapter.TotalDetectedScenes ?? 1), 0m, 1m);
return Math.Clamp(0.25m + (sceneProgress * 0.7m), 0m, 0.95m);
}
return 0.15m;
}
private static string Elapsed(StoryIntelligenceProgressViewModel model)
{
var elapsedMs = model.Chapters.Max(chapter => chapter.TotalDurationMs ?? 0);
var elapsed = TimeSpan.FromMilliseconds(Math.Max(0, elapsedMs));
return elapsed.TotalMinutes >= 1
? $"{Math.Max(1, (int)Math.Round(elapsed.TotalMinutes)):N0} min"
: $"{Math.Max(0, (int)Math.Round(elapsed.TotalSeconds)):N0} sec";
}
private static string EstimateRemaining(StoryIntelligenceProgressViewModel model)
{
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)
{
var chapter = CurrentChapter(model);
return chapter?.CompletedScenes is > 0
? chapter.CompletedScenes.Value.ToString("N0")
: "Waiting";
}
private static string ChapterProgressText(StoryIntelligenceOnboardingChapterViewModel? chapter)
{
if ((chapter?.TotalDetectedScenes ?? 0) <= 0)
{
return "Finding scenes...";
}
var completed = Math.Clamp(chapter?.CompletedScenes ?? 0, 0, chapter?.TotalDetectedScenes ?? 0);
return $"Scene {completed:N0} of {chapter?.TotalDetectedScenes:N0}";
}
private static int ChapterProgressPercent(StoryIntelligenceOnboardingChapterViewModel? chapter)
{
var total = chapter?.TotalDetectedScenes ?? 0;
if (total <= 0)
{
return 0;
}
var completed = Math.Clamp(chapter?.CompletedScenes ?? 0, 0, total);
return Math.Clamp((int)Math.Floor(completed * 100m / total), 0, 100);
}
}