diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index d9e4d07..7a97b4a 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -1059,9 +1059,18 @@ static void StoryIntelligenceHeavyReviewModulesOfferBoundedRendering() foreach (var viewName in heavyViews) { var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding", viewName)); - Assert(view.Contains("const int previewLimit = 25", StringComparison.Ordinal) || view.Contains("const pageSize = 24;", StringComparison.Ordinal), $"{viewName} should cap visible candidates to a small page."); + if (string.Equals(viewName, "StoryIntelligenceLocations.cshtml", StringComparison.Ordinal)) + { + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingStoryIntelligenceService.cs")); + Assert(onboarding.Contains("reviewCandidates.GetLocationReviewAsync(frame.Batch, pageNumber, pageSize)", StringComparison.Ordinal), $"{viewName} should use persisted bounded candidate pages."); + Assert(view.Contains("var locationCandidates = Model.LocationReview.Candidates.ToList();", StringComparison.Ordinal), $"{viewName} should render only the persisted candidate page."); + } + else + { + Assert(view.Contains("const int previewLimit = 25", StringComparison.Ordinal) || view.Contains("const pageSize = 24;", StringComparison.Ordinal), $"{viewName} should cap visible candidates to a small page."); + } Assert(!view.Contains("asp-route-view=\"all\"", StringComparison.Ordinal), $"{viewName} should not expose an unbounded View All path."); - Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal), $"{viewName} should disclose hidden remaining decisions."); + Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal) || view.Contains("PendingCandidateCount", StringComparison.Ordinal), $"{viewName} should disclose hidden remaining decisions."); } var assets = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceAssets.cshtml")); @@ -2700,14 +2709,28 @@ static void LocationReviewUxSupportsLargeCandidateSets() var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceLocations.cshtml")); var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs")); var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceLocationImportService.cs")); + var persisted = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceReviewCandidateService.cs")); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/OnboardingController.cs")); - Assert(view.Contains("const int previewLimit = 25", StringComparison.Ordinal), "Location review should render a bounded candidate batch."); - Assert(view.Contains("hiddenCandidateCount", StringComparison.Ordinal), "Location review should disclose remaining decisions outside the current batch."); + Assert(view.Contains("var locationCandidates = Model.LocationReview.Candidates.ToList();", StringComparison.Ordinal), "Location review should render only the persisted candidate page."); + Assert(viewModels.Contains("PageCount => PendingCandidateCount <= 0 ? 1 : (int)Math.Ceiling(PendingCandidateCount / (double)PageSize)", StringComparison.Ordinal), "52 pending candidates should page as 25 / 25 / 2."); + Assert(viewModels.Contains("ShowingStart", StringComparison.Ordinal) && viewModels.Contains("ShowingEnd", StringComparison.Ordinal), "Location review should show the visible pending range."); Assert(view.Contains("story-review-page-status", StringComparison.Ordinal), "Location review should show bounded review counts."); - Assert(!view.Contains("data-location-page-next", StringComparison.Ordinal), "Location review should not hide a full candidate DOM behind client-side paging."); + Assert(view.Contains("Page @Model.LocationReview.PageNumber", StringComparison.Ordinal), "Location review should show Page X of Y."); + Assert(view.Contains("Showing @Model.LocationReview.ShowingStart", StringComparison.Ordinal), "Location review should show N-M of T pending."); + Assert(view.Contains("name=\"intent\" value=\"previous\"", StringComparison.Ordinal) + && view.Contains("name=\"intent\" value=\"next\"", StringComparison.Ordinal), "Previous and Next should submit the current page so decisions persist before navigation."); + Assert(view.Contains("name=\"PageNumber\"", StringComparison.Ordinal), "Location review should post the current page number."); Assert(view.Contains("EvidenceSummaries", StringComparison.Ordinal), "Location review should expose compact aggregated evidence."); Assert(!view.Contains("data-location-bulk=\"create-all\"", StringComparison.Ordinal), "Location review should not offer bulk create-all."); Assert(!view.Contains("data-location-bulk=\"ignore-all\"", StringComparison.Ordinal), "Location review should not offer bulk ignore-all."); + Assert(viewModels.Contains("public int PageNumber { get; set; } = 1;", StringComparison.Ordinal), "Location review posts should preserve the page being saved."); + Assert(persisted.Contains("pageNumber = Math.Clamp(pageNumber, 1, maxPage)", StringComparison.Ordinal), "Persisted candidate pages should clamp after pending totals change."); + Assert(controller.Contains("ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form, string intent = \"save\")", StringComparison.Ordinal), "Location review POST should accept a save/navigation intent."); + Assert(controller.Contains("pendingCount <= 0", StringComparison.Ordinal) + && controller.Contains("RedirectToAction(nameof(StoryIntelligenceReviewCentre)", StringComparison.Ordinal), "Location review should return to Review Centre only after module-wide pending reaches zero."); + Assert(controller.Contains("RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage })", StringComparison.Ordinal), "Location review should stay in-module while pending candidates remain."); + Assert(controller.Contains("var targetPage = string.Equals(intent, \"previous\"", StringComparison.Ordinal), "Previous navigation should move to the prior persisted page after saving."); Assert(viewModels.Contains("IReadOnlyList EvidenceSummaries", StringComparison.Ordinal), "Location candidates should carry aggregated evidence summaries."); Assert(service.Contains("candidate.Appearances.Any(existing => existing.SceneID == appearance.SceneID", StringComparison.Ordinal), "Location evidence should be deduplicated per scene/observation."); Assert(service.Contains("locations.ListAliasesAsync(location.LocationID)", StringComparison.Ordinal), "Existing Location aliases should participate in matching."); diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 0390f62..65b362b 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -546,7 +546,7 @@ public sealed class OnboardingController( [HttpPost("story-intelligence/locations")] [ValidateAntiForgeryToken] - public async Task ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form) + public async Task ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form, string intent = "save") { var (progress, result) = await storyIntelligence.ImportLocationsAsync(form.BatchID, form); if (progress is null) @@ -555,9 +555,26 @@ public sealed class OnboardingController( } TempData[result.Success ? "OnboardingStoryIntelligenceMessage" : "OnboardingStoryIntelligenceError"] = result.Message; - return result.Success - ? RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }) - : RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID }); + if (!result.Success) + { + return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = Math.Max(1, form.PageNumber) }); + } + + var pendingCount = progress.LocationReview.SummaryNeedsAttentionCount ?? progress.LocationReview.PendingCandidateCount; + if (pendingCount <= 0) + { + return RedirectToAction(nameof(StoryIntelligenceReviewCentre), new { batchId = form.BatchID }); + } + + const int pageSize = 25; + var pageCount = Math.Max(1, (int)Math.Ceiling(pendingCount / (double)pageSize)); + var currentPage = Math.Max(1, form.PageNumber); + var targetPage = string.Equals(intent, "previous", StringComparison.OrdinalIgnoreCase) + ? currentPage - 1 + : currentPage; + targetPage = Math.Clamp(targetPage, 1, pageCount); + + return RedirectToAction(nameof(StoryIntelligenceLocations), new { batchId = form.BatchID, page = targetPage }); } [HttpPost("story-intelligence/assets")] diff --git a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs index c3e817c..1aacd18 100644 --- a/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs +++ b/PlotLine/Services/StoryIntelligenceReviewCandidateService.cs @@ -553,6 +553,10 @@ public sealed class StoryIntelligenceReviewCandidateService( return new CandidatePage([], pageNumber, pageSize, 0, 0, false); } + var maxPage = generation.PendingCount <= 0 + ? 1 + : (int)Math.Ceiling(generation.PendingCount / (double)pageSize); + pageNumber = Math.Clamp(pageNumber, 1, maxPage); var rows = await candidates.ListPageAsync(bookId, module, pageNumber, pageSize); var items = rows.Select(row => JsonSerializer.Deserialize(row.CandidatePayloadJson, JsonOptions)) .Where(item => item is not null) diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 17124c8..a5ecb65 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -451,8 +451,11 @@ public sealed class StoryIntelligenceLocationReviewViewModel public int PageSize { get; init; } = 25; public int TotalCandidateCount { get; init; } public int PendingCandidateCount { get; init; } + public int PageCount => PendingCandidateCount <= 0 ? 1 : (int)Math.Ceiling(PendingCandidateCount / (double)PageSize); + public int ShowingStart => PendingCandidateCount == 0 || Candidates.Count == 0 ? 0 : ((PageNumber - 1) * PageSize) + 1; + public int ShowingEnd => ShowingStart == 0 ? 0 : Math.Min(PendingCandidateCount, ShowingStart + Candidates.Count - 1); public bool HasPreviousPage => PageNumber > 1; - public bool HasNextPage => PageNumber * PageSize < PendingCandidateCount; + public bool HasNextPage => PageNumber < PageCount; public IReadOnlyList ParentLocationOptions { get; init; } = []; public IReadOnlyList SameLocationOptions { get; init; } = []; public IReadOnlyList LocationTargetOptions { get; init; } = []; @@ -516,6 +519,7 @@ public sealed class StoryIntelligenceLocationReviewCandidateViewModel public sealed class StoryIntelligenceLocationImportForm { public Guid BatchID { get; set; } + public int PageNumber { get; set; } = 1; public List Locations { get; set; } = []; } diff --git a/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml index 4108437..130a5be 100644 --- a/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml +++ b/PlotLine/Views/Onboarding/StoryIntelligenceLocations.cshtml @@ -1,9 +1,7 @@ @model StoryIntelligenceProgressViewModel @{ ViewData["Title"] = "Review locations"; - const int previewLimit = 25; - var locationCandidates = Model.LocationReview.Candidates.Take(previewLimit).ToList(); - var hiddenCandidateCount = Math.Max(0, Model.LocationReview.PendingCandidateCount - locationCandidates.Count); + var locationCandidates = Model.LocationReview.Candidates.ToList(); }
@@ -40,14 +38,15 @@ - @if (hiddenCandidateCount > 0) - { - Showing @locationCandidates.Count.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending - } + + Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0") + · Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending +
+
@for (var i = 0; i < locationCandidates.Count; i++) { @@ -197,20 +196,19 @@
- @if (hiddenCandidateCount > 0) - { - Save this batch to continue with @hiddenCandidateCount.ToString("N0") more location decision@(hiddenCandidateCount == 1 ? string.Empty : "s"). - } - Back to Review Centre + + Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0") + · Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending + @if (Model.LocationReview.HasPreviousPage) { - Previous + } @if (Model.LocationReview.HasNextPage) { - Next + } - +
}