From 908660db1c54912bbcb90465d7c72d76b98e20e0 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 6 Jul 2026 21:04:32 +0100 Subject: [PATCH] =?UTF-8?q?Phase=2020AC=20=E2=80=93=20Story=20Intelligence?= =?UTF-8?q?=20Progress=20Page=20UX=20and=20Status=20Clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PlotLine/Controllers/OnboardingController.cs | 22 +- PlotLine/Hubs/StoryIntelligenceHub.cs | 1 + PlotLine/Hubs/WordCompanionFollowHub.cs | 26 ++ PlotLine/Models/StoryIntelligenceModels.cs | 1 + PlotLine/Services/OnboardingService.cs | 1 + .../PersistedStoryIntelligenceRunner.cs | 19 +- PlotLine/Services/StoryIntelligenceService.cs | 23 +- PlotLine/Views/Onboarding/Index.cshtml | 37 ++- .../Views/Onboarding/StoryIntelligence.cshtml | 10 +- .../StoryIntelligenceProgress.cshtml | 226 ++++++++++++++++-- PlotLine/Views/Projects/Index.cshtml | 28 +-- PlotLine/wwwroot/css/onboarding.css | 59 +++++ .../wwwroot/js/story-intelligence-progress.js | 156 +++++++++++- PlotLine/wwwroot/js/word-companion-host.js | 26 +- .../wwwroot/js/word-companion-host.min.js | 8 +- .../wwwroot/js/word-companion-presence.js | 12 + 16 files changed, 540 insertions(+), 115 deletions(-) diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 3bc1f81..680e223 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -20,8 +20,16 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard [HttpGet("scan-review")] public async Task ScanReview(Guid? previewId) { - var model = await onboarding.GetScanReviewAsync(previewId); - return model is null ? NotFound() : View(model); + var model = await onboarding.GetScanReviewAsync(previewId) + ?? (previewId.HasValue ? await onboarding.GetScanReviewAsync() : null); + if (model is null) + { + TempData["ArchiveError"] = "The scan review could not be opened. Please scan the manuscript again."; + await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); + return RedirectToAction(nameof(Index)); + } + + return View(model); } [HttpGet("build-complete")] @@ -61,6 +69,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard return RedirectToAction("Index", "Projects"); } + [HttpPost("scan-manuscript")] + [ValidateAntiForgeryToken] + public async Task ScanManuscript() + { + await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview); + return RedirectToAction(nameof(Index)); + } + [HttpGet("story-intelligence/progress")] public async Task StoryIntelligenceProgress(Guid batchId) { @@ -126,7 +142,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard ? "Review saved. Next: prepare chapters and analyse manuscript." : "Review choices saved."; return readyToImport - ? RedirectToAction(nameof(Index)) + ? RedirectToAction(nameof(StoryIntelligence)) : RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID }); } catch (InvalidOperationException ex) diff --git a/PlotLine/Hubs/StoryIntelligenceHub.cs b/PlotLine/Hubs/StoryIntelligenceHub.cs index 6008d4f..b99d57c 100644 --- a/PlotLine/Hubs/StoryIntelligenceHub.cs +++ b/PlotLine/Hubs/StoryIntelligenceHub.cs @@ -74,6 +74,7 @@ public sealed class StoryIntelligenceHub( FailedScenes = run.FailedScenes, TotalTokens = run.TotalTokens, TotalDurationMs = elapsedMs, + CurrentSceneNumber = run.CompletedScenes, ElapsedTime = FormatDuration(elapsedMs), EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes, totalScenes), ErrorMessage = run.ErrorMessage, diff --git a/PlotLine/Hubs/WordCompanionFollowHub.cs b/PlotLine/Hubs/WordCompanionFollowHub.cs index 47753e2..ddab6b8 100644 --- a/PlotLine/Hubs/WordCompanionFollowHub.cs +++ b/PlotLine/Hubs/WordCompanionFollowHub.cs @@ -106,6 +106,11 @@ public sealed class WordCompanionFollowHub( { var userId = RequireUserId(); var state = await onboarding.GetAsync(userId); + if (state is not null) + { + preview = NormalisePreviewContext(preview, userId, state); + } + if (state is null || state.UserOnboardingStateID != preview.OnboardingID || state.ProjectID != preview.ProjectID @@ -185,4 +190,25 @@ public sealed class WordCompanionFollowHub( private static bool IsMicrosoftWordState(UserOnboardingState state) => string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal) || state.UsesWordCompanion == true; + + private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state) + => new() + { + PreviewID = preview.PreviewID, + UserID = preview.UserID == 0 ? userId : preview.UserID, + OnboardingID = preview.OnboardingID == 0 ? state.UserOnboardingStateID : preview.OnboardingID, + ProjectID = preview.ProjectID == 0 && state.ProjectID.HasValue ? state.ProjectID.Value : preview.ProjectID, + BookID = preview.BookID == 0 && state.BookID.HasValue ? state.BookID.Value : preview.BookID, + Source = preview.Source, + DocumentTitle = preview.DocumentTitle, + CompanionDocumentIdentifier = preview.CompanionDocumentIdentifier, + TotalWordCount = preview.TotalWordCount, + ChapterCount = preview.ChapterCount, + SceneCount = preview.SceneCount, + CharacterCandidateCount = preview.CharacterCandidateCount, + CreatedUtc = preview.CreatedUtc, + Chapters = preview.Chapters, + Scenes = preview.Scenes, + CharacterCandidates = preview.CharacterCandidates + }; } diff --git a/PlotLine/Models/StoryIntelligenceModels.cs b/PlotLine/Models/StoryIntelligenceModels.cs index 285ca98..79df7f6 100644 --- a/PlotLine/Models/StoryIntelligenceModels.cs +++ b/PlotLine/Models/StoryIntelligenceModels.cs @@ -82,6 +82,7 @@ public sealed class StoryIntelligenceRunProgressEvent public int? FailedScenes { get; init; } public int? TotalTokens { get; init; } public long? TotalDurationMs { get; init; } + public int? CurrentSceneNumber { get; init; } public string ElapsedTime { get; init; } = "0 sec"; public string? EstimatedRemaining { get; init; } public string? LatestSceneSummary { get; init; } diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 1883be8..1bbe598 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -77,6 +77,7 @@ public sealed class OnboardingService( { CurrentStep = step, StepNumber = StepNumber(step), + TotalSteps = 9, WritingJourney = state.WritingJourney, WritingSoftware = state.WritingSoftware, ProjectID = state.ProjectID, diff --git a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs index 054c194..2737bd4 100644 --- a/PlotLine/Services/PersistedStoryIntelligenceRunner.cs +++ b/PlotLine/Services/PersistedStoryIntelligenceRunner.cs @@ -268,7 +268,8 @@ public sealed class PersistedStoryIntelligenceRunner( totalDetectedScenes: sceneBlocks.Count, completedScenes: completedScenes, failedScenes: failedScenes, - totalTokens: totals.TotalTokens); + totalTokens: totals.TotalTokens, + currentSceneNumber: block.TemporarySceneNumber); try { @@ -341,6 +342,7 @@ public sealed class PersistedStoryIntelligenceRunner( completedScenes: completedScenes, failedScenes: failedScenes, totalTokens: totals.TotalTokens, + currentSceneNumber: block.TemporarySceneNumber, latestSceneSummary: TrimOptional(sceneAttempt.Parsed?.Summary?.Short, 220), recentDiscoveries: BuildDiscoveries(sceneAttempt.Parsed)); if (!validation.IsValid || validation.Warnings.Count > 0) @@ -363,6 +365,7 @@ public sealed class PersistedStoryIntelligenceRunner( completedScenes: completedScenes, failedScenes: failedScenes, totalTokens: totals.TotalTokens, + currentSceneNumber: block.TemporarySceneNumber, errorMessage: ex.Message); logger.LogWarning(ex, "Story Intelligence scene failed for run {StoryIntelligenceRunID}, scene {TemporarySceneNumber}.", run.StoryIntelligenceRunID, block.TemporarySceneNumber); } @@ -487,6 +490,7 @@ public sealed class PersistedStoryIntelligenceRunner( int? completedScenes = null, int? failedScenes = null, int? totalTokens = null, + int? currentSceneNumber = null, string? latestSceneSummary = null, IReadOnlyList? recentDiscoveries = null, string? errorMessage = null) @@ -507,6 +511,7 @@ public sealed class PersistedStoryIntelligenceRunner( FailedScenes = failedScenes, TotalTokens = totalTokens, TotalDurationMs = elapsedMs, + CurrentSceneNumber = currentSceneNumber, ElapsedTime = FormatDuration(elapsedMs), EstimatedRemaining = EstimateRemaining(elapsedMs, completedScenes ?? 0, totalDetectedScenes ?? 0), LatestSceneSummary = latestSceneSummary, @@ -558,19 +563,19 @@ public sealed class PersistedStoryIntelligenceRunner( .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(3) - .Select(name => $"Character detected: {name}")); + .Select(name => $"Possible character detected: {name}")); discoveries.AddRange((scene.Locations ?? []) .Select(location => TrimOptional(location.Name, 120)) .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(3) - .Select(name => $"Location detected: {name}")); + .Select(name => $"Possible location detected: {name}")); discoveries.AddRange((scene.Assets ?? []) .Select(asset => TrimOptional(asset.Name, 120)) .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(2) - .Select(name => $"Asset detected: {name}")); + .Select(name => $"Possible asset detected: {name}")); discoveries.AddRange((scene.Relationships ?? []) .Select(relationship => BuildRelationshipDiscovery(relationship)) .Where(text => !string.IsNullOrWhiteSpace(text)) @@ -580,7 +585,7 @@ public sealed class PersistedStoryIntelligenceRunner( .Select(clue => TrimOptional(clue.Clue, 160)) .Where(text => !string.IsNullOrWhiteSpace(text)) .Take(2) - .Select(text => $"Timeline clue detected: {text}")); + .Select(text => $"Timeline clue found: {text}")); return discoveries .Distinct(StringComparer.OrdinalIgnoreCase) @@ -599,8 +604,8 @@ public sealed class PersistedStoryIntelligenceRunner( } return string.IsNullOrWhiteSpace(signal) - ? $"Relationship detected: {characterA} and {characterB}" - : $"Relationship detected: {characterA} and {characterB} - {signal}"; + ? $"Relationship signal found: {characterA} and {characterB}" + : $"Relationship signal found: {characterA} and {characterB} - {signal}"; } private static string? TrimOptional(string? value, int maxLength) diff --git a/PlotLine/Services/StoryIntelligenceService.cs b/PlotLine/Services/StoryIntelligenceService.cs index ab93258..da3bea1 100644 --- a/PlotLine/Services/StoryIntelligenceService.cs +++ b/PlotLine/Services/StoryIntelligenceService.cs @@ -147,30 +147,13 @@ public sealed class StoryIntelligenceService( } var state = await onboarding.GetAsync(currentUser.UserId.Value); - var latest = await repository.GetLatestForUserAsync(currentUser.UserId.Value); - if (latest is not null) - { - return new StoryIntelligenceDashboardViewModel - { - ShouldShow = true, - Job = ToProgress(latest), - Title = latest.IsCompleted ? "Story Intelligence ready" : latest.IsActive ? "Story Intelligence preparing" : "Story Intelligence available", - Description = latest.IsCompleted - ? "The analysis framework is prepared for your manuscript." - : latest.IsActive - ? latest.CurrentMessage - : "You can prepare Story Intelligence for your built manuscript.", - ButtonText = latest.IsCompleted ? "View completion" : latest.IsActive ? "View progress" : "Set up Story Intelligence" - }; - } - return state?.ProjectID.HasValue == true && state.BookID.HasValue ? new StoryIntelligenceDashboardViewModel { ShouldShow = true, - Title = "Story Intelligence available", - Description = "Prepare the optional analysis framework for your manuscript.", - ButtonText = "Set up Story Intelligence" + Title = "Import your manuscript", + Description = "Scan your manuscript with the Word Companion, review the chapters, then let PlotDirector prepare scene suggestions.", + ButtonText = "Continue manuscript import" } : new StoryIntelligenceDashboardViewModel(); } diff --git a/PlotLine/Views/Onboarding/Index.cshtml b/PlotLine/Views/Onboarding/Index.cshtml index 5963179..abbe486 100644 --- a/PlotLine/Views/Onboarding/Index.cshtml +++ b/PlotLine/Views/Onboarding/Index.cshtml @@ -12,7 +12,7 @@ new { Label = "Connect Word", Order = 5 }, new { Label = "Scan", Order = 6 }, new { Label = "Review", Order = 7 }, - new { Label = "Prepare Chapters", Order = 8 }, + new { Label = "Analyse", Order = 8 }, new { Label = "Complete", Order = 9 } }; } @@ -294,6 +294,7 @@
@@ -339,20 +340,17 @@ {
-

Review saved. PlotDirector can now create the approved structure.

-

Ready to prepare approved chapters for Story Intelligence.

+

Review saved. PlotDirector can now analyse the approved chapters.

+

Next: analyse the manuscript and review suggested scenes.

- + + Analyse manuscript + }
@@ -384,9 +382,24 @@ { Go to Project Overview } -
- -
+ @if (Model.IsMicrosoftWordPath && string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal)) + { + Analyse manuscript + } + else if (Model.IsMicrosoftWordPath && Model.ScanState.IsComplete) + { + Review the scan above to continue. + } + else if (Model.IsMicrosoftWordPath) + { + Scan your manuscript to continue. + } + else + { +
+ +
+ } } diff --git a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml index 33f9913..7495292 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligence.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligence.cshtml @@ -25,7 +25,12 @@

Analysis has already started

You can continue reviewing progress for the approved chapters.

- Review scenes +
+ Review scenes +
+ +
+
} else @@ -70,6 +75,9 @@ }
+
+ +
diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml index 687f5ac..c50c529 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceProgress.cshtml @@ -1,8 +1,14 @@ @model StoryIntelligenceProgressViewModel +@using System.Security.Claims +@inject IConfiguration Configuration @{ ViewData["Title"] = "Review scenes"; var runIds = string.Join(",", Model.Chapters.Select(chapter => chapter.RunID)); var progressPercent = ProgressPercent(Model); + var userEmail = User.FindFirstValue(ClaimTypes.Email); + var adminEmails = Configuration.GetSection("Admin:AllowedEmails").Get() ?? []; + var isAdmin = !string.IsNullOrWhiteSpace(userEmail) + && adminEmails.Any(email => string.Equals(email, userEmail, StringComparison.OrdinalIgnoreCase)); }
@@ -10,11 +16,12 @@ 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()">

@Model.BookTitle

Review scenes

-

PlotDirector is reading the approved chapters and turning them into scenes you can review before creating them.

+

PlotDirector is reading the approved chapters and preparing scene suggestions for you to review.

@if (TempData["OnboardingStoryIntelligenceError"] is string error) @@ -26,24 +33,36 @@
@message
} +
+ @if (Model.Chapters.Any(chapter => chapter.CanCommit || chapter.HasCompletedCommit)) + { + Scenes can be created now. Characters, locations, assets and relationships remain suggestions for later review. + } + else + { + Nothing has been added to your project yet. PlotDirector is preparing suggestions. You will review them before anything is created. + } +
+
- Overall progress + Import progress @progressPercent%
- Chapters completed - @Model.CompletedChapterCount / @Model.ChapterCount + Chapters read + @Model.CompletedChapterCount of @Model.ChapterCount
- Scenes analysed - @Model.TotalCompletedScenes / @Model.TotalDetectedScenes + Scenes read + @Model.TotalCompletedScenes so far
- Failures + Needs review @(Model.FailedChapterCount + Model.TotalFailedScenes)
+

These are chapters or scenes PlotDirector could not fully analyse. You can review them after the run completes.

@@ -51,15 +70,19 @@
-

Reading now

+

Latest from your manuscript

Current chapter @(CurrentChapter(Model)?.ChapterTitle ?? "Waiting")
+
+ Current scene + @CurrentScene(Model) +
Current stage - @(CurrentChapter(Model)?.CurrentStage ?? "Queued") + @FriendlyStage(CurrentChapter(Model)?.CurrentStage, CurrentChapter(Model)?.Status)
Elapsed @@ -67,10 +90,10 @@
Estimated remaining - Calculating + @EstimateRemaining(Model)
-

@(CurrentChapter(Model)?.CurrentMessage ?? "Waiting for analysis to begin.")

+

@FriendlyMessage(CurrentChapter(Model))

    @@ -80,7 +103,7 @@
    @foreach (var chapter in Model.Chapters.OrderBy(chapter => chapter.ChapterNumber)) { -
    +
    @chapter.ChapterNumber. @chapter.ChapterTitle @ChapterStatusLabel(chapter) @@ -88,23 +111,27 @@
    Status - @chapter.Status + @FriendlyStatus(chapter.Status)
    - Scenes + Scenes detected @((chapter.CompletedScenes ?? 0).ToString("N0")) / @((chapter.TotalDetectedScenes ?? 0).ToString("N0"))
    - Failed + Needs review @((chapter.FailedScenes ?? 0).ToString("N0"))
    -
    - Tokens - @(chapter.TotalTokens?.ToString("N0") ?? "-") -
    -

    @(chapter.CurrentMessage ?? string.Empty)

    +

    @FriendlyMessage(chapter)

    + @if ((chapter.FailedScenes ?? 0) > 0) + { +

    Needs review after analysis completes.

    + } + else + { + + } @if (!string.IsNullOrWhiteSpace(chapter.ErrorMessage)) {

    @chapter.ErrorMessage

    @@ -150,6 +177,27 @@ Review needed }
    + @if (isAdmin) + { +
    + Diagnostics +
    +
    Run ID
    +
    @chapter.RunID
    +
    Raw status
    +
    @chapter.Status
    +
    Raw stage
    +
    @(chapter.CurrentStage ?? "-")
    +
    Tokens
    +
    @(chapter.TotalTokens?.ToString("N0") ?? "-")
    + @if (!string.IsNullOrWhiteSpace(chapter.FailureStage)) + { +
    Failure stage
    +
    @chapter.FailureStage
    + } +
    +
    + } }
    @@ -159,6 +207,9 @@
    @if (Model.BatchID.HasValue) { +
    + +
    @@ -202,6 +253,80 @@ return "Reviewing"; } + private static string FriendlyStatus(string? status) + => status switch + { + StoryIntelligenceRunStatuses.Pending => "Waiting", + StoryIntelligenceRunStatuses.Running => "Reading", + StoryIntelligenceRunStatuses.Completed => "Ready to review", + StoryIntelligenceRunStatuses.CompletedWithWarnings => "Ready to review, with notes", + StoryIntelligenceRunStatuses.Failed => "Needs attention", + 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 attention"; + } + + if (status is StoryIntelligenceRunStatuses.Cancelled) + { + return "Cancelled"; + } + + return stage switch + { + "Queued" => "Waiting", + StoryIntelligenceFailureStages.DocumentRead => "Preparing chapter", + StoryIntelligenceFailureStages.ChapterStructure => "Finding scenes", + StoryIntelligenceFailureStages.SceneSplit => "Preparing scenes", + StoryIntelligenceFailureStages.SceneIntelligence => "Reading scenes", + StoryIntelligenceFailureStages.Validation => "Checking results", + StoryIntelligenceFailureStages.Persistence => "Saving analysis", + StoryIntelligenceRunStatuses.Completed => "Ready to 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 attention before scenes can be created."; + } + + return chapter.CurrentStage switch + { + "Queued" => "Waiting to read this chapter.", + StoryIntelligenceFailureStages.DocumentRead => "Preparing 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) @@ -214,12 +339,34 @@ return 0; } - if (model.TotalDetectedScenes > 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) { - return Math.Clamp((int)Math.Round(model.TotalCompletedScenes * 100m / Math.Max(1, model.TotalDetectedScenes)), 0, 100); + return 1m; } - return Math.Clamp((int)Math.Round(model.CompletedChapterCount * 100m / Math.Max(1, model.ChapterCount)), 0, 100); + if (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) @@ -230,4 +377,37 @@ ? $"{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 "Almost done"; + } + + if (model.TotalCompletedScenes <= 0) + { + return "Estimating..."; + } + + var remainingScenes = Math.Max(0, model.TotalDetectedScenes - model.TotalCompletedScenes); + if (remainingScenes == 0) + { + return "Almost done"; + } + + var elapsedMs = model.Chapters.Max(chapter => chapter.TotalDurationMs ?? 0); + var averageSceneMinutes = Math.Max(1, (int)Math.Ceiling(TimeSpan.FromMilliseconds(elapsedMs).TotalMinutes / Math.Max(1, model.TotalCompletedScenes))); + var low = Math.Max(1, averageSceneMinutes * remainingScenes); + var high = Math.Max(low + 1, low * 2); + return $"About {low:N0}-{high:N0} minutes"; + } + + private static string CurrentScene(StoryIntelligenceProgressViewModel model) + { + var chapter = CurrentChapter(model); + return chapter?.CompletedScenes is > 0 + ? chapter.CompletedScenes.Value.ToString("N0") + : "Waiting"; + } } diff --git a/PlotLine/Views/Projects/Index.cshtml b/PlotLine/Views/Projects/Index.cshtml index c436cc3..4276b6b 100644 --- a/PlotLine/Views/Projects/Index.cshtml +++ b/PlotLine/Views/Projects/Index.cshtml @@ -50,36 +50,13 @@ @if (Model.StoryIntelligence.ShouldShow) {

    Story Intelligence

    @Model.StoryIntelligence.Title

    -

    @Model.StoryIntelligence.Description

    - @if (Model.StoryIntelligence.Job is not null) - { -
    - @Model.StoryIntelligence.Job.Status - @Model.StoryIntelligence.Job.ProgressPercent% - -
    - } +

    @Model.StoryIntelligence.Description

    - @if (Model.StoryIntelligence.Job?.IsActive == true) - { - @Model.StoryIntelligence.ButtonText - } - else if (Model.StoryIntelligence.Job?.IsCompleted == true) - { - @Model.StoryIntelligence.ButtonText - } - else - { - @Model.StoryIntelligence.ButtonText - } + @Model.StoryIntelligence.ButtonText
    } @@ -345,7 +322,6 @@ else } @section Scripts { -