Add persisted location review pagination

This commit is contained in:
Nick Beckley 2026-09-02 18:52:11 +00:00
parent 7990cb244e
commit c82430da04
5 changed files with 71 additions and 25 deletions

View File

@ -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<string> 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.");

View File

@ -546,7 +546,7 @@ public sealed class OnboardingController(
[HttpPost("story-intelligence/locations")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportStoryIntelligenceLocations(StoryIntelligenceLocationImportForm form)
public async Task<IActionResult> 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")]

View File

@ -553,6 +553,10 @@ public sealed class StoryIntelligenceReviewCandidateService(
return new CandidatePage<TCandidate>([], 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<TCandidate>(row.CandidatePayloadJson, JsonOptions))
.Where(item => item is not null)

View File

@ -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<StoryIntelligenceLocationParentOptionViewModel> ParentLocationOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceLocationIdentityOptionViewModel> SameLocationOptions { get; init; } = [];
public IReadOnlyList<StoryIntelligenceLocationTargetOptionViewModel> 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<StoryIntelligenceLocationImportChoiceForm> Locations { get; set; } = [];
}

View File

@ -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();
}
<section class="onboarding-shell" aria-labelledby="story-location-title">
@ -40,14 +38,15 @@
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="link-existing">Use exact matches</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="expand">Expand all</button>
<button class="btn btn-outline-secondary btn-sm" type="button" data-location-bulk="collapse">Collapse all</button>
@if (hiddenCandidateCount > 0)
{
<span class="story-review-page-status">Showing @locationCandidates.Count.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending</span>
}
<span class="story-review-page-status">
Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0")
&middot; Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending
</span>
</div>
<form asp-action="ImportStoryIntelligenceLocations" method="post" data-location-review-form data-story-review-form>
<input type="hidden" name="BatchID" value="@Model.BatchID" />
<input type="hidden" name="PageNumber" value="@Model.LocationReview.PageNumber" />
<div class="story-character-card-grid">
@for (var i = 0; i < locationCandidates.Count; i++)
{
@ -197,20 +196,19 @@
</div>
<div class="onboarding-actions">
@if (hiddenCandidateCount > 0)
{
<span class="text-muted">Save this batch to continue with @hiddenCandidateCount.ToString("N0") more location decision@(hiddenCandidateCount == 1 ? string.Empty : "s").</span>
}
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceReviewCentre" asp-route-batchId="@Model.BatchID">Back to Review Centre</a>
<span class="text-muted">
Page @Model.LocationReview.PageNumber.ToString("N0") of @Model.LocationReview.PageCount.ToString("N0")
&middot; Showing @Model.LocationReview.ShowingStart.ToString("N0")-@Model.LocationReview.ShowingEnd.ToString("N0") of @Model.LocationReview.PendingCandidateCount.ToString("N0") pending
</span>
@if (Model.LocationReview.HasPreviousPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.LocationReview.PageNumber - 1)">Previous</a>
<button class="btn btn-outline-secondary" type="submit" name="intent" value="previous" data-busy-label="Saving page...">Previous</button>
}
@if (Model.LocationReview.HasNextPage)
{
<a class="btn btn-outline-secondary" asp-action="StoryIntelligenceLocations" asp-route-batchId="@Model.BatchID" asp-route-page="@(Model.LocationReview.PageNumber + 1)">Next</a>
<button class="btn btn-outline-secondary" type="submit" name="intent" value="next" data-busy-label="Saving page...">Next</button>
}
<button class="btn btn-primary" type="submit" data-busy-label="Saving locations...">Save location decisions</button>
<button class="btn btn-primary" type="submit" name="intent" value="save" data-busy-label="Saving locations...">Save location decisions</button>
</div>
</form>
}