Phase 12B: Continuity Explorer Page.

This commit is contained in:
Nick Beckley 2026-06-14 19:24:59 +01:00
parent eafa9927c7
commit e7aabe8b41
5 changed files with 773 additions and 20 deletions

View File

@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize]
public sealed class ContinuityExplorerController(IStoryStateService storyState) : Controller
{
public async Task<IActionResult> Index([FromQuery] ContinuityFilter filter)
{
if (filter.ProjectID == 0)
{
return BadRequest("ProjectID is required.");
}
var model = await storyState.GetContinuityExplorerAsync(filter);
return model is null ? NotFound() : View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult ApplyFilters(ContinuityFilter filter)
{
return RedirectToAction(nameof(Index), filter);
}
}

View File

@ -114,6 +114,10 @@ public interface ISceneService
public interface IStoryStateService
{
Task<StoryStateViewModel> GetStoryStateAsync(int sceneId);
Task<ContinuityExplorerViewModel?> GetContinuityExplorerAsync(ContinuityFilter filter);
Task<List<CharacterJourneyViewModel>> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter);
Task<List<AssetJourneyViewModel>> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter);
Task<List<LocationActivityViewModel>> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter);
}
public interface IPlotService
@ -2204,6 +2208,7 @@ public sealed class TimelineService(
}
public sealed class StoryStateService(
IProjectRepository projects,
IBookRepository books,
IChapterRepository chapters,
ISceneRepository scenes,
@ -2227,7 +2232,7 @@ public sealed class StoryStateService(
return new StoryStateViewModel();
}
var timeline = await timelineRepository.GetByProjectAsync(book.ProjectID, book.BookID, false, [], false);
var timeline = await timelineRepository.GetByProjectAsync(book.ProjectID, null, false, [], false);
var orderedScenes = OrderScenes(timeline).ToList();
var currentSceneIndex = orderedScenes.FindIndex(x => x.SceneID == sceneId);
if (currentSceneIndex < 0)
@ -2235,22 +2240,252 @@ public sealed class StoryStateService(
return new StoryStateViewModel();
}
var sceneIndexes = orderedScenes
.Select((scene, index) => new { scene.SceneID, Index = index })
.ToDictionary(x => x.SceneID, x => x.Index);
var sceneIndexes = BuildSceneIndexes(orderedScenes);
var scenesThroughCurrent = orderedScenes.Take(currentSceneIndex + 1).ToList();
var allowedSceneIds = scenesThroughCurrent.Select(x => x.SceneID).ToHashSet();
var allLocations = await locations.ListByProjectAsync(book.ProjectID);
var characterTimeline = await characters.GetTimelineAsync(book.ProjectID, book.BookID);
var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, book.BookID);
var characterTimeline = await characters.GetTimelineAsync(book.ProjectID, null);
var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, null);
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughCurrent);
var assetCustody = await GetAssetCustodyThroughCurrentAsync(assetTimeline.Assets);
return new StoryStateViewModel
var storyState = new StoryStateViewModel
{
Characters = BuildCharacterState(characterTimeline.Characters, characterTimeline.Appearances, sceneId, allowedSceneIds, sceneIndexes),
Assets = BuildAssetState(assetTimeline.Assets, assetTimeline.Events, assetLocations, assetCustody, sceneId, allowedSceneIds, sceneIndexes, orderedScenes.ToDictionary(x => x.SceneID))
};
storyState.Locations = BuildLocationState(allLocations, storyState);
return storyState;
}
public async Task<ContinuityExplorerViewModel?> GetContinuityExplorerAsync(ContinuityFilter filter)
{
NormaliseFilter(filter);
var project = await projects.GetAsync(filter.ProjectID);
if (project is null)
{
return null;
}
var timeline = await timelineRepository.GetByProjectAsync(filter.ProjectID, null, false, [], false);
var orderedScenes = OrderScenes(timeline).ToList();
var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList();
var allCharacters = await characters.ListCharactersAsync(filter.ProjectID);
var allAssets = await assets.ListAssetsAsync(filter.ProjectID);
var allLocations = await locations.ListByProjectAsync(filter.ProjectID);
filter.SelectedSceneID ??= scopedScenes.FirstOrDefault()?.SceneID ?? orderedScenes.FirstOrDefault()?.SceneID;
var model = new ContinuityExplorerViewModel
{
Project = project,
Filter = filter,
BookOptions = timeline.Books.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == filter.BookID)).ToList(),
ChapterOptions = timeline.Chapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == filter.ChapterID)).ToList(),
SceneOptions = orderedScenes.Select(x => new SelectListItem($"Scene {x.SceneNumber:g}: {x.SceneTitle}", x.SceneID.ToString(), x.SceneID == filter.SelectedSceneID)).ToList(),
CharacterOptions = allCharacters.Select(x => new SelectListItem(x.CharacterName, x.CharacterID.ToString(), filter.CharacterIDs.Contains(x.CharacterID))).ToList(),
AssetOptions = allAssets.Select(x => new SelectListItem(x.AssetName, x.StoryAssetID.ToString(), filter.AssetIDs.Contains(x.StoryAssetID))).ToList(),
LocationOptions = allLocations.Select(x => new SelectListItem(x.LocationPath, x.LocationID.ToString(), filter.LocationIDs.Contains(x.LocationID))).ToList()
};
if (string.Equals(filter.DisplayMode, "StoryState", StringComparison.OrdinalIgnoreCase) && filter.SelectedSceneID.HasValue)
{
model.StoryState = await GetStoryStateAsync(filter.SelectedSceneID.Value);
FilterStoryState(model.StoryState, filter);
}
else if (string.Equals(filter.DisplayMode, "Journey", StringComparison.OrdinalIgnoreCase))
{
var characterIds = filter.IncludeCharacters
? (filter.CharacterIDs.Any() ? filter.CharacterIDs : allCharacters.Select(x => x.CharacterID)).ToList()
: [];
var assetIds = filter.IncludeAssets
? (filter.AssetIDs.Any() ? filter.AssetIDs : allAssets.Select(x => x.StoryAssetID)).ToList()
: [];
var characterJourney = new List<CharacterJourneyViewModel>();
foreach (var characterId in characterIds)
{
characterJourney.AddRange(await GetCharacterJourneyAsync(filter.ProjectID, characterId, filter));
}
var assetJourney = new List<AssetJourneyViewModel>();
foreach (var assetId in assetIds)
{
assetJourney.AddRange(await GetAssetJourneyAsync(filter.ProjectID, assetId, filter));
}
model.CharacterJourney = characterJourney;
model.AssetJourney = assetJourney;
}
else if (string.Equals(filter.DisplayMode, "LocationActivity", StringComparison.OrdinalIgnoreCase))
{
var locationIds = filter.IncludeLocations
? (filter.LocationIDs.Any() ? filter.LocationIDs : allLocations.Select(x => x.LocationID)).ToList()
: [];
var activity = new List<LocationActivityViewModel>();
foreach (var locationId in locationIds)
{
activity.AddRange(await GetLocationActivityAsync(filter.ProjectID, locationId, filter));
}
model.LocationActivity = activity
.OrderBy(x => scopedScenes.FindIndex(scene => scene.SceneID == x.SceneID))
.ThenBy(x => x.LocationName)
.ToList();
}
return model;
}
public async Task<List<CharacterJourneyViewModel>> GetCharacterJourneyAsync(int projectId, int characterId, ContinuityFilter filter)
{
var context = await GetRangeContextAsync(projectId, filter);
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
var character = characterTimeline.Characters.FirstOrDefault(x => x.CharacterID == characterId);
if (character is null)
{
return [];
}
var rows = characterTimeline.Appearances
.Where(x => x.CharacterID == characterId && x.LocationID.HasValue && context.AllowedSceneIds.Contains(x.SceneID))
.GroupBy(x => x.SceneID)
.Select(x => x.OrderByDescending(row => row.UpdatedDate).First())
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ToList();
var result = new List<CharacterJourneyViewModel>();
int? previousLocationId = null;
foreach (var row in rows)
{
if (row.LocationID == previousLocationId)
{
continue;
}
previousLocationId = row.LocationID;
result.Add(new CharacterJourneyViewModel
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName,
SceneID = row.SceneID,
SceneLabel = context.SceneLabels.GetValueOrDefault(row.SceneID, $"Scene {row.SceneNumber:g}"),
Location = row.LocationPath ?? row.LocationName ?? "Unknown"
});
}
return result;
}
public async Task<List<AssetJourneyViewModel>> GetAssetJourneyAsync(int projectId, int assetId, ContinuityFilter filter)
{
var context = await GetRangeContextAsync(projectId, filter);
var assetTimeline = await assets.GetTimelineAsync(projectId, null);
var asset = assetTimeline.Assets.FirstOrDefault(x => x.StoryAssetID == assetId);
if (asset is null)
{
return [];
}
var assetLocations = await GetAssetLocationsThroughCurrentAsync(context.ScopedScenes);
var assetCustody = await assets.ListCustodyByAssetAsync(assetId);
var assignments = BuildAssetAssignments(assetTimeline.Events, assetLocations, assetCustody, context.AllowedSceneIds);
var rows = assignments
.Where(x => x.StoryAssetID == assetId)
.GroupBy(x => x.SceneID)
.Select(x => x.OrderByDescending(row => row.Priority).ThenByDescending(row => row.UpdatedDate).First())
.OrderBy(x => context.SceneIndexes.GetValueOrDefault(x.SceneID, -1))
.ToList();
var result = new List<AssetJourneyViewModel>();
string? previousLabel = null;
foreach (var row in rows)
{
if (string.Equals(previousLabel, row.Label, StringComparison.OrdinalIgnoreCase))
{
continue;
}
previousLabel = row.Label;
result.Add(new AssetJourneyViewModel
{
StoryAssetID = asset.StoryAssetID,
AssetName = asset.AssetName,
SceneID = row.SceneID,
SceneLabel = context.SceneLabels.GetValueOrDefault(row.SceneID, "Unknown scene"),
HolderOrLocation = row.Label
});
}
return result;
}
public async Task<List<LocationActivityViewModel>> GetLocationActivityAsync(int projectId, int locationId, ContinuityFilter filter)
{
var context = await GetRangeContextAsync(projectId, filter);
var allLocations = await locations.ListByProjectAsync(projectId);
var location = allLocations.FirstOrDefault(x => x.LocationID == locationId);
if (location is null)
{
return [];
}
var characterTimeline = await characters.GetTimelineAsync(projectId, null);
var assetLocations = await GetAssetLocationsThroughCurrentAsync(context.ScopedScenes);
var characterIds = filter.CharacterIDs.ToHashSet();
var assetIds = filter.AssetIDs.ToHashSet();
var result = new List<LocationActivityViewModel>();
foreach (var scene in context.ScopedScenes)
{
var sceneCharacters = filter.IncludeCharacters
? characterTimeline.Appearances
.Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!characterIds.Any() || characterIds.Contains(x.CharacterID)))
.Select(x => x.CharacterName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x)
.ToList()
: [];
var sceneAssets = filter.IncludeAssets
? assetLocations
.Where(x => x.SceneID == scene.SceneID && x.LocationID == locationId && (!assetIds.Any() || assetIds.Contains(x.StoryAssetID)))
.Select(x => x.AssetName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x)
.ToList()
: [];
if (!sceneCharacters.Any() && !sceneAssets.Any())
{
continue;
}
result.Add(new LocationActivityViewModel
{
LocationID = location.LocationID,
LocationName = location.LocationPath,
SceneID = scene.SceneID,
SceneLabel = SceneLabel(scene.SceneNumber, scene.SceneTitle),
CharactersPresent = sceneCharacters.Any() ? string.Join(", ", sceneCharacters) : "None",
AssetsPresent = sceneAssets.Any() ? string.Join(", ", sceneAssets) : "None"
});
}
return result;
}
private async Task<RangeContext> GetRangeContextAsync(int projectId, ContinuityFilter filter)
{
NormaliseFilter(filter);
var timeline = await timelineRepository.GetByProjectAsync(projectId, null, false, [], false);
var orderedScenes = OrderScenes(timeline).ToList();
var scopedScenes = ApplySceneRange(orderedScenes, timeline.Chapters, filter).ToList();
return new RangeContext(
scopedScenes,
scopedScenes.Select(x => x.SceneID).ToHashSet(),
BuildSceneIndexes(orderedScenes),
orderedScenes.ToDictionary(x => x.SceneID, x => SceneLabel(x.SceneNumber, x.SceneTitle)));
}
private static IEnumerable<Scene> OrderScenes(TimelineData timeline)
@ -2267,6 +2502,36 @@ public sealed class StoryStateService(
.ThenBy(scene => scene.SceneID);
}
private static IEnumerable<Scene> ApplySceneRange(IReadOnlyList<Scene> orderedScenes, IReadOnlyList<Chapter> chapters, ContinuityFilter filter)
{
if (string.Equals(filter.SceneRangeMode, "Book", StringComparison.OrdinalIgnoreCase) && filter.BookID.HasValue)
{
var chapterIds = chapters.Where(x => x.BookID == filter.BookID.Value).Select(x => x.ChapterID).ToHashSet();
return orderedScenes.Where(x => chapterIds.Contains(x.ChapterID));
}
if (string.Equals(filter.SceneRangeMode, "Chapter", StringComparison.OrdinalIgnoreCase) && filter.ChapterID.HasValue)
{
return orderedScenes.Where(x => x.ChapterID == filter.ChapterID.Value);
}
if (string.Equals(filter.SceneRangeMode, "Custom", StringComparison.OrdinalIgnoreCase) && (filter.StartSceneID.HasValue || filter.EndSceneID.HasValue))
{
var startIndex = filter.StartSceneID.HasValue ? orderedScenes.ToList().FindIndex(x => x.SceneID == filter.StartSceneID.Value) : 0;
var endIndex = filter.EndSceneID.HasValue ? orderedScenes.ToList().FindIndex(x => x.SceneID == filter.EndSceneID.Value) : orderedScenes.Count - 1;
startIndex = startIndex < 0 ? 0 : startIndex;
endIndex = endIndex < 0 ? orderedScenes.Count - 1 : endIndex;
if (startIndex > endIndex)
{
(startIndex, endIndex) = (endIndex, startIndex);
}
return orderedScenes.Skip(startIndex).Take(endIndex - startIndex + 1);
}
return orderedScenes;
}
private async Task<IReadOnlyList<SceneAssetLocation>> GetAssetLocationsThroughCurrentAsync(IReadOnlyList<Scene> scenesThroughCurrent)
{
var rows = new List<SceneAssetLocation>();
@ -2324,6 +2589,7 @@ public sealed class StoryStateService(
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName,
LocationID = latest.LocationID,
CurrentLocation = latest.LocationPath ?? latest.LocationName ?? "Unknown",
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmed = SceneLabel(latest.SceneNumber, latest.SceneTitle)
@ -2342,19 +2608,7 @@ public sealed class StoryStateService(
IReadOnlyDictionary<int, int> sceneIndexes,
IReadOnlyDictionary<int, Scene> scenesById)
{
var stateAssignments = assetEvents
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.ToStateName))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.ToStateName!, 1, x.UpdatedDate));
var locationAssignments = assetLocations
.Where(x => allowedSceneIds.Contains(x.SceneID))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.LocationPath, 2, x.UpdatedDate));
var custodyAssignments = assetCustody
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.CustodianSummary))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.CustodianSummary!, 3, x.UpdatedDate));
var assignments = stateAssignments
.Concat(locationAssignments)
.Concat(custodyAssignments)
.ToList();
var assignments = BuildAssetAssignments(assetEvents, assetLocations, assetCustody, allowedSceneIds);
return projectAssets
.OrderByDescending(x => x.Importance)
@ -2381,6 +2635,7 @@ public sealed class StoryStateService(
{
StoryAssetID = asset.StoryAssetID,
AssetName = asset.AssetName,
LocationID = latest.LocationID,
CurrentHolderOrLocation = latest.Label,
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene)
@ -2391,6 +2646,92 @@ public sealed class StoryStateService(
.ToList();
}
private static List<AssetStateAssignment> BuildAssetAssignments(
IReadOnlyList<AssetEvent> assetEvents,
IReadOnlyList<SceneAssetLocation> assetLocations,
IReadOnlyList<AssetCustodyEvent> assetCustody,
IReadOnlySet<int> allowedSceneIds)
{
var stateAssignments = assetEvents
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.ToStateName))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, null, x.ToStateName!, 1, x.UpdatedDate));
var locationAssignments = assetLocations
.Where(x => allowedSceneIds.Contains(x.SceneID))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, x.LocationID, x.LocationPath, 2, x.UpdatedDate));
var custodyAssignments = assetCustody
.Where(x => allowedSceneIds.Contains(x.SceneID) && !string.IsNullOrWhiteSpace(x.CustodianSummary))
.Select(x => new AssetStateAssignment(x.StoryAssetID, x.SceneID, null, x.CustodianSummary!, 3, x.UpdatedDate));
return stateAssignments.Concat(locationAssignments).Concat(custodyAssignments).ToList();
}
private static List<LocationStoryStateViewModel> BuildLocationState(IReadOnlyList<LocationItem> allLocations, StoryStateViewModel storyState)
{
return allLocations
.OrderBy(x => x.LocationPath)
.Select(location => new LocationStoryStateViewModel
{
LocationID = location.LocationID,
LocationName = location.LocationName,
LocationPath = location.LocationPath,
CharactersPresent = storyState.Characters
.Where(x => x.LocationID == location.LocationID)
.Select(x => x.CharacterName)
.OrderBy(x => x)
.ToList(),
AssetsPresent = storyState.Assets
.Where(x => x.LocationID == location.LocationID)
.Select(x => x.AssetName)
.OrderBy(x => x)
.ToList()
})
.Where(x => x.CharactersPresent.Any() || x.AssetsPresent.Any())
.ToList();
}
private static void FilterStoryState(StoryStateViewModel storyState, ContinuityFilter filter)
{
storyState.Characters = filter.IncludeCharacters
? storyState.Characters.Where(x => !filter.CharacterIDs.Any() || filter.CharacterIDs.Contains(x.CharacterID)).ToList()
: [];
storyState.Assets = filter.IncludeAssets
? storyState.Assets.Where(x => !filter.AssetIDs.Any() || filter.AssetIDs.Contains(x.StoryAssetID)).ToList()
: [];
storyState.Locations = filter.IncludeLocations
? storyState.Locations.Where(x => !filter.LocationIDs.Any() || filter.LocationIDs.Contains(x.LocationID)).ToList()
: [];
}
private static Dictionary<int, int> BuildSceneIndexes(IReadOnlyList<Scene> orderedScenes)
{
return orderedScenes
.Select((scene, index) => new { scene.SceneID, Index = index })
.ToDictionary(x => x.SceneID, x => x.Index);
}
private static void NormaliseFilter(ContinuityFilter filter)
{
if (filter.ProjectID <= 0)
{
return;
}
if (!filter.EntityTypes.Any())
{
filter.EntityTypes = ["Characters", "Assets", "Locations"];
}
if (string.IsNullOrWhiteSpace(filter.SceneRangeMode))
{
filter.SceneRangeMode = "EntireProject";
}
if (string.IsNullOrWhiteSpace(filter.DisplayMode))
{
filter.DisplayMode = "StoryState";
}
}
private static string SceneLabel(decimal sceneNumber, string sceneTitle)
{
return string.IsNullOrWhiteSpace(sceneTitle)
@ -2401,9 +2742,16 @@ public sealed class StoryStateService(
private sealed record AssetStateAssignment(
int StoryAssetID,
int SceneID,
int? LocationID,
string Label,
int Priority,
DateTime UpdatedDate);
private sealed record RangeContext(
IReadOnlyList<Scene> ScopedScenes,
IReadOnlySet<int> AllowedSceneIds,
IReadOnlyDictionary<int, int> SceneIndexes,
IReadOnlyDictionary<int, string> SceneLabels);
}
public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots, IProjectActivityService activity) : IPlotService

View File

@ -241,12 +241,14 @@ public sealed class StoryStateViewModel
{
public List<CharacterStoryStateViewModel> Characters { get; set; } = [];
public List<AssetStoryStateViewModel> Assets { get; set; } = [];
public List<LocationStoryStateViewModel> Locations { get; set; } = [];
}
public sealed class CharacterStoryStateViewModel
{
public int CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public int? LocationID { get; set; }
public string CurrentLocation { get; set; } = "Unknown";
public string State { get; set; } = "Unknown";
public string LastConfirmed { get; set; } = "Never";
@ -256,11 +258,85 @@ public sealed class AssetStoryStateViewModel
{
public int StoryAssetID { get; set; }
public string AssetName { get; set; } = string.Empty;
public int? LocationID { get; set; }
public string CurrentHolderOrLocation { get; set; } = "Unknown";
public string State { get; set; } = "Unknown";
public string LastConfirmed { get; set; } = "Never";
}
public sealed class LocationStoryStateViewModel
{
public int LocationID { get; set; }
public string LocationName { get; set; } = string.Empty;
public string LocationPath { get; set; } = string.Empty;
public List<string> CharactersPresent { get; set; } = [];
public List<string> AssetsPresent { get; set; } = [];
}
public sealed class ContinuityFilter
{
public int ProjectID { get; set; }
public List<string> EntityTypes { get; set; } = ["Characters", "Assets", "Locations"];
public List<int> CharacterIDs { get; set; } = [];
public List<int> AssetIDs { get; set; } = [];
public List<int> LocationIDs { get; set; } = [];
public string SceneRangeMode { get; set; } = "EntireProject";
public int? BookID { get; set; }
public int? ChapterID { get; set; }
public int? StartSceneID { get; set; }
public int? EndSceneID { get; set; }
public string DisplayMode { get; set; } = "StoryState";
public int? SelectedSceneID { get; set; }
public bool IncludeCharacters => EntityTypes.Contains("Characters", StringComparer.OrdinalIgnoreCase);
public bool IncludeAssets => EntityTypes.Contains("Assets", StringComparer.OrdinalIgnoreCase);
public bool IncludeLocations => EntityTypes.Contains("Locations", StringComparer.OrdinalIgnoreCase);
}
public sealed class ContinuityExplorerViewModel
{
public Project Project { get; set; } = new();
public ContinuityFilter Filter { get; set; } = new();
public StoryStateViewModel StoryState { get; set; } = new();
public IReadOnlyList<CharacterJourneyViewModel> CharacterJourney { get; set; } = [];
public IReadOnlyList<AssetJourneyViewModel> AssetJourney { get; set; } = [];
public IReadOnlyList<LocationActivityViewModel> LocationActivity { get; set; } = [];
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> ChapterOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> CharacterOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> AssetOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> LocationOptions { get; set; } = [];
}
public sealed class CharacterJourneyViewModel
{
public int CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public int SceneID { get; set; }
public string SceneLabel { get; set; } = string.Empty;
public string Location { get; set; } = string.Empty;
}
public sealed class AssetJourneyViewModel
{
public int StoryAssetID { get; set; }
public string AssetName { get; set; } = string.Empty;
public int SceneID { get; set; }
public string SceneLabel { get; set; } = string.Empty;
public string HolderOrLocation { get; set; } = string.Empty;
}
public sealed class LocationActivityViewModel
{
public int LocationID { get; set; }
public string LocationName { get; set; } = string.Empty;
public int SceneID { get; set; }
public string SceneLabel { get; set; } = string.Empty;
public string CharactersPresent { get; set; } = string.Empty;
public string AssetsPresent { get; set; } = string.Empty;
}
public sealed class SceneWorkflowEditViewModel
{
public int SceneID { get; set; }

View File

@ -0,0 +1,300 @@
@model ContinuityExplorerViewModel
@{
ViewData["Title"] = $"Continuity Explorer - {Model.Project.ProjectName}";
ViewData["ProjectSection"] = "Continuity Explorer";
}
@functions {
private static string StateBadgeClass(string state) => state switch
{
"Explicit" => "bg-success",
"Inferred" => "bg-secondary",
_ => "bg-warning text-dark"
};
private bool IsEntitySelected(string entityType) =>
Model.Filter.EntityTypes.Contains(entityType, StringComparer.OrdinalIgnoreCase);
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<span>Continuity Explorer</span>
</nav>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading">
<div>
<p class="eyebrow">Continuity</p>
<h1>Continuity Explorer</h1>
<p class="lead-text">Investigate where characters, assets, and locations stand across the story.</p>
</div>
</div>
<section class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Filters</strong>
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#continuity-filter-panel" aria-expanded="true" aria-controls="continuity-filter-panel">Toggle</button>
</div>
<div class="collapse show" id="continuity-filter-panel">
<div class="card-body">
<form asp-action="ApplyFilters" method="post">
<input type="hidden" name="ProjectID" value="@Model.Project.ProjectID" />
<div class="row g-3">
<div class="col-lg-3">
<label class="form-label">Entity types</label>
<div class="d-grid gap-1">
<label class="form-check">
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Characters" checked="@IsEntitySelected("Characters")" />
<span class="form-check-label">Characters</span>
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Assets" checked="@IsEntitySelected("Assets")" />
<span class="form-check-label">Assets</span>
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="EntityTypes" value="Locations" checked="@IsEntitySelected("Locations")" />
<span class="form-check-label">Locations</span>
</label>
</div>
</div>
<div class="col-lg-9">
<div class="row g-2">
<div class="col-md-4">
<label class="form-label" for="CharacterIDs">Characters</label>
<select class="form-select form-select-sm" id="CharacterIDs" name="CharacterIDs" asp-items="Model.CharacterOptions" multiple size="6"></select>
</div>
<div class="col-md-4">
<label class="form-label" for="AssetIDs">Assets</label>
<select class="form-select form-select-sm" id="AssetIDs" name="AssetIDs" asp-items="Model.AssetOptions" multiple size="6"></select>
</div>
<div class="col-md-4">
<label class="form-label" for="LocationIDs">Locations</label>
<select class="form-select form-select-sm" id="LocationIDs" name="LocationIDs" asp-items="Model.LocationOptions" multiple size="6"></select>
</div>
</div>
</div>
<div class="col-lg-4">
<label class="form-label">Scene range</label>
<div class="d-grid gap-1">
<label class="form-check">
<input class="form-check-input" type="radio" name="SceneRangeMode" value="EntireProject" checked="@(Model.Filter.SceneRangeMode == "EntireProject")" />
<span class="form-check-label">Entire Project</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Book" checked="@(Model.Filter.SceneRangeMode == "Book")" />
<span class="form-check-label">Book</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Chapter" checked="@(Model.Filter.SceneRangeMode == "Chapter")" />
<span class="form-check-label">Chapter</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="SceneRangeMode" value="Custom" checked="@(Model.Filter.SceneRangeMode == "Custom")" />
<span class="form-check-label">Custom Scene Range</span>
</label>
</div>
</div>
<div class="col-lg-8">
<div class="row g-2">
<div class="col-md-6">
<label class="form-label" for="BookID">Book</label>
<select class="form-select form-select-sm" id="BookID" name="BookID" asp-items="Model.BookOptions">
<option value="">Any book</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="ChapterID">Chapter</label>
<select class="form-select form-select-sm" id="ChapterID" name="ChapterID" asp-items="Model.ChapterOptions">
<option value="">Any chapter</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="StartSceneID">Start scene</label>
<select class="form-select form-select-sm" id="StartSceneID" name="StartSceneID" asp-items="Model.SceneOptions">
<option value="">First scene</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label" for="EndSceneID">End scene</label>
<select class="form-select form-select-sm" id="EndSceneID" name="EndSceneID" asp-items="Model.SceneOptions">
<option value="">Last scene</option>
</select>
</div>
</div>
</div>
<div class="col-lg-4">
<label class="form-label">Display mode</label>
<div class="d-grid gap-1">
<label class="form-check">
<input class="form-check-input" type="radio" name="DisplayMode" value="StoryState" checked="@(Model.Filter.DisplayMode == "StoryState")" />
<span class="form-check-label">Story State</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="DisplayMode" value="Journey" checked="@(Model.Filter.DisplayMode == "Journey")" />
<span class="form-check-label">Journey</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="DisplayMode" value="LocationActivity" checked="@(Model.Filter.DisplayMode == "LocationActivity")" />
<span class="form-check-label">Location Activity</span>
</label>
</div>
</div>
<div class="col-lg-8">
<label class="form-label" for="SelectedSceneID">Story State scene</label>
<select class="form-select form-select-sm" id="SelectedSceneID" name="SelectedSceneID" asp-items="Model.SceneOptions"></select>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Apply filters</button>
<a class="btn btn-outline-secondary" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Reset</a>
</div>
</form>
</div>
</div>
</section>
@if (Model.Filter.DisplayMode == "StoryState")
{
<section class="story-bible-section">
<h2>Story State</h2>
@if (!Model.StoryState.Characters.Any() && !Model.StoryState.Assets.Any() && !Model.StoryState.Locations.Any())
{
<p class="muted">No matching continuity information found.</p>
}
else
{
@if (Model.Filter.IncludeCharacters)
{
<h3>Characters</h3>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle">
<thead><tr><th>Character</th><th>Current Location</th><th>State</th><th>Last Confirmed</th></tr></thead>
<tbody>
@foreach (var row in Model.StoryState.Characters)
{
<tr><td>@row.CharacterName</td><td>@row.CurrentLocation</td><td><span class="badge @StateBadgeClass(row.State)">@row.State</span></td><td>@row.LastConfirmed</td></tr>
}
</tbody>
</table>
</div>
}
@if (Model.Filter.IncludeAssets)
{
<h3>Assets</h3>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle">
<thead><tr><th>Asset</th><th>Holder / Location</th><th>State</th><th>Last Confirmed</th></tr></thead>
<tbody>
@foreach (var row in Model.StoryState.Assets)
{
<tr><td>@row.AssetName</td><td>@row.CurrentHolderOrLocation</td><td><span class="badge @StateBadgeClass(row.State)">@row.State</span></td><td>@row.LastConfirmed</td></tr>
}
</tbody>
</table>
</div>
}
@if (Model.Filter.IncludeLocations)
{
<h3>Locations</h3>
@if (!Model.StoryState.Locations.Any())
{
<p class="muted">No matching location presence found.</p>
}
else
{
<div class="row g-3">
@foreach (var location in Model.StoryState.Locations)
{
<div class="col-lg-6">
<article class="story-bible-card h-100">
<h3>@location.LocationPath</h3>
<p><strong>Characters:</strong> @(location.CharactersPresent.Any() ? string.Join(", ", location.CharactersPresent) : "None")</p>
<p><strong>Assets:</strong> @(location.AssetsPresent.Any() ? string.Join(", ", location.AssetsPresent) : "None")</p>
</article>
</div>
}
</div>
}
}
}
</section>
}
else if (Model.Filter.DisplayMode == "Journey")
{
<section class="story-bible-section">
<h2>Journey</h2>
@if (!Model.CharacterJourney.Any() && !Model.AssetJourney.Any())
{
<p class="muted">No movement recorded.</p>
}
else
{
@foreach (var group in Model.CharacterJourney.GroupBy(x => new { x.CharacterID, x.CharacterName }))
{
<h3>@group.Key.CharacterName</h3>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle">
<thead><tr><th>Scene</th><th>Location</th></tr></thead>
<tbody>
@foreach (var row in group)
{
<tr><td>@row.SceneLabel</td><td>@row.Location</td></tr>
}
</tbody>
</table>
</div>
}
@foreach (var group in Model.AssetJourney.GroupBy(x => new { x.StoryAssetID, x.AssetName }))
{
<h3>@group.Key.AssetName</h3>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle">
<thead><tr><th>Scene</th><th>Holder / Location</th></tr></thead>
<tbody>
@foreach (var row in group)
{
<tr><td>@row.SceneLabel</td><td>@row.HolderOrLocation</td></tr>
}
</tbody>
</table>
</div>
}
}
</section>
}
else
{
<section class="story-bible-section">
<h2>Location Activity</h2>
@if (!Model.LocationActivity.Any())
{
<p class="muted">No matching continuity information found.</p>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead><tr><th>Location</th><th>Scene</th><th>Characters Present</th><th>Assets Present</th></tr></thead>
<tbody>
@foreach (var row in Model.LocationActivity)
{
<tr><td>@row.LocationName</td><td>@row.SceneLabel</td><td>@row.CharactersPresent</td><td>@row.AssetsPresent</td></tr>
}
</tbody>
</table>
</div>
}
</section>
}

View File

@ -20,6 +20,7 @@
<a class="pw-project-nav__link @(ActiveClass("Metrics"))" asp-controller="ProjectMetrics" asp-action="Index" asp-route-projectId="@projectId">Metrics</a>
<a class="pw-project-nav__link @(ActiveClass("Scenarios"))" asp-controller="Scenarios" asp-action="Index" asp-route-projectId="@projectId">Scenarios</a>
<a class="pw-project-nav__link @(ActiveClass("Story Bible"))" asp-controller="StoryBible" asp-action="Index" asp-route-projectId="@projectId">Story Bible</a>
<a class="pw-project-nav__link @(ActiveClass("Continuity Explorer"))" asp-controller="ContinuityExplorer" asp-action="Index" asp-route-projectId="@projectId">Continuity Explorer</a>
<a class="pw-project-nav__link @(ActiveClass("Health"))" asp-controller="Analytics" asp-action="Index" asp-route-projectId="@projectId">Health</a>
<a class="pw-project-nav__link @(ActiveClass("Warnings"))" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@projectId">Warnings</a>
<a class="pw-project-nav__link @(ActiveClass("Archive"))" asp-controller="Archives" asp-action="Index" asp-route-projectId="@projectId">Archive</a>