Fix onboarding import wizard sequencing

This commit is contained in:
Nick Beckley 2026-08-25 18:55:51 +00:00
parent c8148245c9
commit 24f2120cb2
11 changed files with 85 additions and 41 deletions

View File

@ -1589,7 +1589,12 @@ static void CoreImportWizardOrdersScenesBeforeCharacters()
Assert(review.Contains("value=\"structure\"", StringComparison.Ordinal), "Chapter review should submit to structural import, not character review."); Assert(review.Contains("value=\"structure\"", StringComparison.Ordinal), "Chapter review should submit to structural import, not character review.");
Assert(controller.Contains("var readyToImport = string.Equals(intent, \"structure\"", StringComparison.Ordinal), "Controller should treat structure intent as the scene-creation gate."); Assert(controller.Contains("var readyToImport = string.Equals(intent, \"structure\"", StringComparison.Ordinal), "Controller should treat structure intent as the scene-creation gate.");
Assert(controller.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character review should be guarded until structural import exists.");
Assert(controller.Contains("continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport", StringComparison.Ordinal), "Structure-save errors should remain on chapter review rather than jumping to characters.");
Assert(controller.Contains("FinaliseCharacterReviewAsync", StringComparison.Ordinal), "Character review should have a distinct post-structure finalisation path."); Assert(controller.Contains("FinaliseCharacterReviewAsync", StringComparison.Ordinal), "Character review should have a distinct post-structure finalisation path.");
Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n review = new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Saving chapter review should validate only structure before scene import.");
Assert(onboarding.Contains("ValidateScanStructure(review, preview);\n\n var chapterDecisions", StringComparison.Ordinal), "Building approved structure should not validate unresolved character decisions.");
Assert(onboarding.Contains("ValidateScanReview(new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Character validation should run when finalising character review.");
Assert(onboarding.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character finalisation should refuse to run before structural scenes exist."); Assert(onboarding.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character finalisation should refuse to run before structural scenes exist.");
Assert(complete.Contains("Review characters", StringComparison.Ordinal), "After Word sync, the next onboarding step should be character review."); Assert(complete.Contains("Review characters", StringComparison.Ordinal), "After Word sync, the next onboarding step should be character review.");
Assert(complete.Contains("Word may now be closed; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion."); Assert(complete.Contains("Word may now be closed; keep this browser workflow open", StringComparison.Ordinal), "Word safe-close must be distinct from browser/core-import completion.");
@ -1889,10 +1894,11 @@ static void OnboardingWizardKeepsCharacterPagesInsideOneMainStep()
var storyService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/OnboardingStoryIntelligenceService.cs")); var storyService = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Services/OnboardingStoryIntelligenceService.cs"));
Assert(!header.Contains("BuildDynamicSteps", StringComparison.Ordinal), "Onboarding header should not create one top-level step per character page."); Assert(!header.Contains("BuildDynamicSteps", StringComparison.Ordinal), "Onboarding header should not create one top-level step per character page.");
Assert(header.Contains("new(\"Review Characters\", 8)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step."); Assert(header.Contains("new(\"Import Scenes\", 8)", StringComparison.Ordinal), "Onboarding header should include scene import as a distinct main step.");
Assert(review.Contains("var currentStep = isCharacterStep ? 8 : 7;", StringComparison.Ordinal), "Scan review should keep every character page on the same main wizard step."); Assert(header.Contains("new(\"Review Characters\", 9)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step.");
Assert(review.Contains("var currentStep = isCharacterStep ? 9 : 7;", StringComparison.Ordinal), "Scan review should keep every character page on the same main wizard step.");
Assert(review.Contains("onboarding-subprogress", StringComparison.Ordinal), "Character pagination should be shown as section-level progress."); Assert(review.Contains("onboarding-subprogress", StringComparison.Ordinal), "Character pagination should be shown as section-level progress.");
Assert(storyService.Contains("const int totalWizardSteps = 11;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed wizard step count."); Assert(storyService.Contains("const int totalWizardSteps = 12;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed wizard step count.");
} }
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel() static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()

View File

@ -60,6 +60,14 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
var build = await onboarding.GetBuildResultAsync(model.PreviewID);
if (build is null || build.SceneMappings.Count == 0)
{
TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing characters.";
await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview);
return RedirectToAction(nameof(Index));
}
ViewData["ScanReviewStep"] = "characters"; ViewData["ScanReviewStep"] = "characters";
ViewData["CharacterReviewPage"] = Math.Max(1, page); ViewData["CharacterReviewPage"] = Math.Max(1, page);
return View("ScanReview", model); return View("ScanReview", model);
@ -622,6 +630,17 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return NotFound(); return NotFound();
} }
if (continueToCharacters)
{
var build = await onboarding.GetBuildResultAsync(form.PreviewID);
if (build is null || build.SceneMappings.Count == 0)
{
TempData["ArchiveError"] = "Import the approved chapters and scenes before reviewing characters.";
await onboarding.SetCurrentStepAsync(OnboardingSteps.NextPathPreview);
return RedirectToAction(nameof(Index));
}
}
TempData["OnboardingReviewMessage"] = readyToImport TempData["OnboardingReviewMessage"] = readyToImport
? "Chapter review saved. Next: import scenes and update Word." ? "Chapter review saved. Next: import scenes and update Word."
: continueToCharacters ? "Chapter and scene review saved. Next: review detected characters." : continueToCharacters ? "Chapter and scene review saved. Next: review detected characters."
@ -642,7 +661,7 @@ public sealed class OnboardingController(IOnboardingService onboarding, IOnboard
return NotFound(); return NotFound();
} }
if (readyToImport || continueCharacterReview || backCharacterReview) if (continueToCharacters || continueCharacterReview || backCharacterReview || completeCoreImport)
{ {
ViewData["ScanReviewStep"] = "characters"; ViewData["ScanReviewStep"] = "characters";
ViewData["CharacterReviewPage"] = readyToImport ViewData["CharacterReviewPage"] = readyToImport

View File

@ -253,7 +253,7 @@ public sealed class WordCompanionFollowHub(
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{ {
Status = "Complete", Status = "Complete",
Message = "Manuscript structure has been created and saved. Word and this browser page may now be closed.", Message = "Manuscript structure has been created and saved. Word may now be closed; keep this browser workflow open to review characters.",
PercentComplete = 100 PercentComplete = 100
}); });
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildMarkerSyncCompleted", new { buildId }); await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildMarkerSyncCompleted", new { buildId });

View File

@ -202,7 +202,7 @@ public sealed class OnboardingService(
if (readyToImport) if (readyToImport)
{ {
await scanPreviews.SaveReviewAsync(userId, review); await scanPreviews.SaveReviewAsync(userId, review);
ValidateScanReview(review, preview); ValidateScanStructure(review, preview);
review = new ManuscriptScanReviewDecision review = new ManuscriptScanReviewDecision
{ {
PreviewID = review.PreviewID, PreviewID = review.PreviewID,
@ -236,7 +236,7 @@ public sealed class OnboardingService(
} }
await PublishBuildProgress(userId, preview.PreviewID, "Creating chapters...", 10, progress); await PublishBuildProgress(userId, preview.PreviewID, "Creating chapters...", 10, progress);
ValidateScanReview(review, preview); ValidateScanStructure(review, preview);
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList(); var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();
@ -382,6 +382,14 @@ public sealed class OnboardingService(
}; };
}) })
.ToList(); .ToList();
ValidateScanReview(new ManuscriptScanReviewDecision
{
PreviewID = review.PreviewID,
Status = review.Status,
Chapters = review.Chapters,
Scenes = review.Scenes,
Characters = characterDecisions
}, preview);
var characterMappings = new List<OnboardingBuildCharacterMap>(); var characterMappings = new List<OnboardingBuildCharacterMap>();
foreach (var decision in characterDecisions.Where(IsIncludedCanonicalCharacter).OrderBy(item => item.Name).ThenBy(item => item.TemporaryCharacterKey)) foreach (var decision in characterDecisions.Where(IsIncludedCanonicalCharacter).OrderBy(item => item.Name).ThenBy(item => item.TemporaryCharacterKey))

View File

@ -71,9 +71,9 @@ public sealed class OnboardingStoryIntelligenceService(
var included = IncludedChapters(preview, review); var included = IncludedChapters(preview, review);
var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID); var existingBatch = await batchStore.GetLatestForPreviewAsync(userId, preview.PreviewID);
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText)); var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
const int storySummaryStep = 9; const int storySummaryStep = 10;
const int storyContextStep = 10; const int storyContextStep = 11;
const int totalWizardSteps = 11; const int totalWizardSteps = 12;
return new StoryIntelligenceOverviewViewModel return new StoryIntelligenceOverviewViewModel
{ {
PreviewID = preview.PreviewID, PreviewID = preview.PreviewID,

View File

@ -127,9 +127,9 @@ public sealed class StoryIntelligenceOverviewViewModel
public int ApprovedChapterCount { get; init; } public int ApprovedChapterCount { get; init; }
public int ApprovedWordCount { get; init; } public int ApprovedWordCount { get; init; }
public int MissingChapterTextCount { get; init; } public int MissingChapterTextCount { get; init; }
public int WizardCurrentStep { get; init; } = 9; public int WizardCurrentStep { get; init; } = 10;
public int WizardContextStep { get; init; } = 10; public int WizardContextStep { get; init; } = 11;
public int WizardTotalSteps { get; init; } = 10; public int WizardTotalSteps { get; init; } = 12;
public string? Message { get; init; } public string? Message { get; init; }
public Guid? ExistingBatchID { get; init; } public Guid? ExistingBatchID { get; init; }
public StoryIntelligenceJobProgress? ExistingJob { get; init; } public StoryIntelligenceJobProgress? ExistingJob { get; init; }

View File

@ -6,15 +6,7 @@
<section class="onboarding-shell" aria-labelledby="build-complete-title"> <section class="onboarding-shell" aria-labelledby="build-complete-title">
<div class="onboarding-panel onboarding-review-panel"> <div class="onboarding-panel onboarding-review-panel">
<ol class="onboarding-stepper onboarding-stepper--complete" aria-label="Story setup journey"> <partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = coreComplete ? 12 : 9, TotalSteps = 12 })" />
@foreach (var label in new[] { "Welcome", "Writing Preferences", "Project", "Book", "Connect Word", "Scan", "Review", "Create Structure", "Complete" })
{
<li class="is-complete">
<span></span>
<strong>@label</strong>
</li>
}
</ol>
<div class="onboarding-copy"> <div class="onboarding-copy">
<p class="eyebrow">Core Import Complete</p> <p class="eyebrow">Core Import Complete</p>
<div class="onboarding-success-heading"> <div class="onboarding-success-heading">

View File

@ -3,11 +3,21 @@
@{ @{
ViewData["Title"] = "Set up PlotDirector"; ViewData["Title"] = "Set up PlotDirector";
var currentJourneyOrder = CurrentJourneyOrder(Model); var currentJourneyOrder = CurrentJourneyOrder(Model);
var structureReadyToImport = string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal);
var reviewedScanAvailable = Model.ScanState.IsComplete || structureReadyToImport;
} }
<section class="onboarding-shell" aria-labelledby="onboarding-title"> <section class="onboarding-shell" aria-labelledby="onboarding-title">
<div class="onboarding-panel"> <div class="onboarding-panel">
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = currentJourneyOrder })" /> <partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = currentJourneyOrder })" />
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
{
<div class="alert alert-success">@reviewMessage</div>
}
@if (TempData["ArchiveError"] is string archiveError)
{
<div class="alert alert-danger">@archiveError</div>
}
@if (Model.CurrentStep == OnboardingSteps.Welcome) @if (Model.CurrentStep == OnboardingSteps.Welcome)
{ {
@ -229,11 +239,11 @@
else else
{ {
<div class="onboarding-copy"> <div class="onboarding-copy">
<p class="eyebrow">Step @currentJourneyOrder of 10</p> <p class="eyebrow">Step @currentJourneyOrder of 12</p>
@if (Model.IsMicrosoftWordPath) @if (Model.IsMicrosoftWordPath)
{ {
<h1 id="onboarding-title">@(currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")</h1> <h1 id="onboarding-title">@(structureReadyToImport ? "Import scenes and Word markers" : currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")</h1>
<p>Open your manuscript in Word and start the Companion. When it is connected, use the wizard button below to scan and continue.</p> <p>@(structureReadyToImport ? "Keep Word and this browser page open while PlotDirector detects scene boundaries, creates the manuscript structure, inserts bookmark IDs, and saves the document." : "Open your manuscript in Word and start the Companion. When it is connected, use the wizard button below to scan and continue.")</p>
} }
else else
{ {
@ -314,10 +324,17 @@
</section> </section>
} }
<div class="onboarding-actions"> <div class="onboarding-actions">
@if (Model.IsMicrosoftWordPath && reviewedScanAvailable && Model.ScanState.PreviewID.HasValue)
{
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="ScanReview" asp-route-previewId="@Model.ScanState.PreviewID">Back to chapter review</a>
}
else
{
<form asp-controller="Onboarding" asp-action="Back" method="post"> <form asp-controller="Onboarding" asp-action="Back" method="post">
<button class="btn btn-outline-secondary" type="submit" name="currentStep" value="@OnboardingSteps.NextPathPreview">Back</button> <button class="btn btn-outline-secondary" type="submit" name="currentStep" value="@OnboardingSteps.NextPathPreview">Back</button>
</form> </form>
@if (Model.IsMicrosoftWordPath && string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal)) }
@if (Model.IsMicrosoftWordPath && structureReadyToImport)
{ {
<button class="btn btn-primary" <button class="btn btn-primary"
type="button" type="button"
@ -444,13 +461,13 @@
OnboardingSteps.WritingSoftware => 3, OnboardingSteps.WritingSoftware => 3,
OnboardingSteps.Project => 4, OnboardingSteps.Project => 4,
OnboardingSteps.Book => 5, OnboardingSteps.Book => 5,
OnboardingSteps.NextPathPreview when !model.IsMicrosoftWordPath => 10, OnboardingSteps.NextPathPreview when !model.IsMicrosoftWordPath => 11,
OnboardingSteps.NextPathPreview when string.Equals(model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal) => 9, OnboardingSteps.NextPathPreview when string.Equals(model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal) => 8,
OnboardingSteps.NextPathPreview when model.ScanState.IsComplete => 7, OnboardingSteps.NextPathPreview when model.ScanState.IsComplete => 7,
OnboardingSteps.NextPathPreview when model.ScanState.IsRunning || model.ScanState.IsFailed || model.ScanState.PercentComplete.HasValue => 6, OnboardingSteps.NextPathPreview when model.ScanState.IsRunning || model.ScanState.IsFailed || model.ScanState.PercentComplete.HasValue => 6,
OnboardingSteps.NextPathPreview when model.CompanionPresence.IsConnected => 6, OnboardingSteps.NextPathPreview when model.CompanionPresence.IsConnected => 6,
OnboardingSteps.NextPathPreview => 6, OnboardingSteps.NextPathPreview => 6,
OnboardingSteps.Complete => 10, OnboardingSteps.Complete => 12,
_ => 1 _ => 1
}; };
} }

View File

@ -31,8 +31,8 @@
var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1; var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1;
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount); var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList(); var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
var currentStep = isCharacterStep ? 8 : 7; var currentStep = isCharacterStep ? 9 : 7;
const int totalSteps = 11; const int totalSteps = 12;
var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}"; var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}";
var duplicateCanonicalNames = primaryCharacters var duplicateCanonicalNames = primaryCharacters
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey)) .Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
@ -67,7 +67,7 @@
<div class="onboarding-copy"> <div class="onboarding-copy">
<p class="eyebrow">Step @currentStep of @totalSteps</p> <p class="eyebrow">Step @currentStep of @totalSteps</p>
<h1 id="scan-review-title">@(isCharacterStep ? "Review Characters" : "Review Chapters")</h1> <h1 id="scan-review-title">@(isCharacterStep ? "Review Characters" : "Review Chapters")</h1>
<p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the character section." : "Confirm the chapter and scene structure before reviewing detected characters.")</p> <p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the character section." : "Confirm the chapter structure before PlotDirector detects scenes with AI and updates Word markers.")</p>
</div> </div>
@if (TempData["OnboardingReviewMessage"] is string reviewMessage) @if (TempData["OnboardingReviewMessage"] is string reviewMessage)
@ -192,7 +192,7 @@
</div> </div>
} }
<div class="onboarding-actions onboarding-actions--inline"> <div class="onboarding-actions onboarding-actions--inline">
<button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapters and import scenes</button> <button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapter review</button>
</div> </div>
</div> </div>
@ -364,7 +364,7 @@
else else
{ {
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a> <a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
<button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapters and import scenes</button> <button class="btn btn-primary" type="submit" name="intent" value="structure">Save chapter review</button>
} }
</div> </div>
</form> </form>

View File

@ -36,10 +36,11 @@
new("Book", 5), new("Book", 5),
new("Connect Word", 6), new("Connect Word", 6),
new("Review Chapters", 7), new("Review Chapters", 7),
new("Review Characters", 8), new("Import Scenes", 8),
new("Story Summary", 9), new("Review Characters", 9),
new("Story Context", 10), new("Story Summary", 10),
new("Complete", 11) new("Story Context", 11),
new("Complete", 12)
]; ];
private static string StepClass(int order, int currentStep) private static string StepClass(int order, int currentStep)

View File

@ -220,6 +220,7 @@
if (!previewId) { if (!previewId) {
return; return;
} }
pendingBuildPreviewId = previewId;
button.disabled = true; button.disabled = true;
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => { document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
node.classList.add("is-running"); node.classList.add("is-running");