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 public interface IStoryStateService
{ {
Task<StoryStateViewModel> GetStoryStateAsync(int sceneId); 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 public interface IPlotService
@ -2204,6 +2208,7 @@ public sealed class TimelineService(
} }
public sealed class StoryStateService( public sealed class StoryStateService(
IProjectRepository projects,
IBookRepository books, IBookRepository books,
IChapterRepository chapters, IChapterRepository chapters,
ISceneRepository scenes, ISceneRepository scenes,
@ -2227,7 +2232,7 @@ public sealed class StoryStateService(
return new StoryStateViewModel(); 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 orderedScenes = OrderScenes(timeline).ToList();
var currentSceneIndex = orderedScenes.FindIndex(x => x.SceneID == sceneId); var currentSceneIndex = orderedScenes.FindIndex(x => x.SceneID == sceneId);
if (currentSceneIndex < 0) if (currentSceneIndex < 0)
@ -2235,22 +2240,252 @@ public sealed class StoryStateService(
return new StoryStateViewModel(); return new StoryStateViewModel();
} }
var sceneIndexes = orderedScenes var sceneIndexes = BuildSceneIndexes(orderedScenes);
.Select((scene, index) => new { scene.SceneID, Index = index })
.ToDictionary(x => x.SceneID, x => x.Index);
var scenesThroughCurrent = orderedScenes.Take(currentSceneIndex + 1).ToList(); var scenesThroughCurrent = orderedScenes.Take(currentSceneIndex + 1).ToList();
var allowedSceneIds = scenesThroughCurrent.Select(x => x.SceneID).ToHashSet(); 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 characterTimeline = await characters.GetTimelineAsync(book.ProjectID, null);
var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, book.BookID); var assetTimeline = await assets.GetTimelineAsync(book.ProjectID, null);
var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughCurrent); var assetLocations = await GetAssetLocationsThroughCurrentAsync(scenesThroughCurrent);
var assetCustody = await GetAssetCustodyThroughCurrentAsync(assetTimeline.Assets); var assetCustody = await GetAssetCustodyThroughCurrentAsync(assetTimeline.Assets);
return new StoryStateViewModel var storyState = new StoryStateViewModel
{ {
Characters = BuildCharacterState(characterTimeline.Characters, characterTimeline.Appearances, sceneId, allowedSceneIds, sceneIndexes), 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)) 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) private static IEnumerable<Scene> OrderScenes(TimelineData timeline)
@ -2267,6 +2502,36 @@ public sealed class StoryStateService(
.ThenBy(scene => scene.SceneID); .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) private async Task<IReadOnlyList<SceneAssetLocation>> GetAssetLocationsThroughCurrentAsync(IReadOnlyList<Scene> scenesThroughCurrent)
{ {
var rows = new List<SceneAssetLocation>(); var rows = new List<SceneAssetLocation>();
@ -2324,6 +2589,7 @@ public sealed class StoryStateService(
{ {
CharacterID = character.CharacterID, CharacterID = character.CharacterID,
CharacterName = character.CharacterName, CharacterName = character.CharacterName,
LocationID = latest.LocationID,
CurrentLocation = latest.LocationPath ?? latest.LocationName ?? "Unknown", CurrentLocation = latest.LocationPath ?? latest.LocationName ?? "Unknown",
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred", State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmed = SceneLabel(latest.SceneNumber, latest.SceneTitle) LastConfirmed = SceneLabel(latest.SceneNumber, latest.SceneTitle)
@ -2342,19 +2608,7 @@ public sealed class StoryStateService(
IReadOnlyDictionary<int, int> sceneIndexes, IReadOnlyDictionary<int, int> sceneIndexes,
IReadOnlyDictionary<int, Scene> scenesById) IReadOnlyDictionary<int, Scene> scenesById)
{ {
var stateAssignments = assetEvents var assignments = BuildAssetAssignments(assetEvents, assetLocations, assetCustody, allowedSceneIds);
.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();
return projectAssets return projectAssets
.OrderByDescending(x => x.Importance) .OrderByDescending(x => x.Importance)
@ -2381,6 +2635,7 @@ public sealed class StoryStateService(
{ {
StoryAssetID = asset.StoryAssetID, StoryAssetID = asset.StoryAssetID,
AssetName = asset.AssetName, AssetName = asset.AssetName,
LocationID = latest.LocationID,
CurrentHolderOrLocation = latest.Label, CurrentHolderOrLocation = latest.Label,
State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred", State = latest.SceneID == currentSceneId ? "Explicit" : "Inferred",
LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene) LastConfirmed = scenesById.TryGetValue(latest.SceneID, out var scene)
@ -2391,6 +2646,92 @@ public sealed class StoryStateService(
.ToList(); .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) private static string SceneLabel(decimal sceneNumber, string sceneTitle)
{ {
return string.IsNullOrWhiteSpace(sceneTitle) return string.IsNullOrWhiteSpace(sceneTitle)
@ -2401,9 +2742,16 @@ public sealed class StoryStateService(
private sealed record AssetStateAssignment( private sealed record AssetStateAssignment(
int StoryAssetID, int StoryAssetID,
int SceneID, int SceneID,
int? LocationID,
string Label, string Label,
int Priority, int Priority,
DateTime UpdatedDate); 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 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<CharacterStoryStateViewModel> Characters { get; set; } = [];
public List<AssetStoryStateViewModel> Assets { get; set; } = []; public List<AssetStoryStateViewModel> Assets { get; set; } = [];
public List<LocationStoryStateViewModel> Locations { get; set; } = [];
} }
public sealed class CharacterStoryStateViewModel public sealed class CharacterStoryStateViewModel
{ {
public int CharacterID { get; set; } public int CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty; public string CharacterName { get; set; } = string.Empty;
public int? LocationID { get; set; }
public string CurrentLocation { get; set; } = "Unknown"; public string CurrentLocation { get; set; } = "Unknown";
public string State { get; set; } = "Unknown"; public string State { get; set; } = "Unknown";
public string LastConfirmed { get; set; } = "Never"; public string LastConfirmed { get; set; } = "Never";
@ -256,11 +258,85 @@ public sealed class AssetStoryStateViewModel
{ {
public int StoryAssetID { get; set; } public int StoryAssetID { get; set; }
public string AssetName { get; set; } = string.Empty; public string AssetName { get; set; } = string.Empty;
public int? LocationID { get; set; }
public string CurrentHolderOrLocation { get; set; } = "Unknown"; public string CurrentHolderOrLocation { get; set; } = "Unknown";
public string State { get; set; } = "Unknown"; public string State { get; set; } = "Unknown";
public string LastConfirmed { get; set; } = "Never"; 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 sealed class SceneWorkflowEditViewModel
{ {
public int SceneID { get; set; } 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("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("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("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("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("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> <a class="pw-project-nav__link @(ActiveClass("Archive"))" asp-controller="Archives" asp-action="Index" asp-route-projectId="@projectId">Archive</a>