diff --git a/PlotLine.Tests/StoryPointTests.cs b/PlotLine.Tests/StoryPointTests.cs index 2ad6872..0b33bd7 100644 --- a/PlotLine.Tests/StoryPointTests.cs +++ b/PlotLine.Tests/StoryPointTests.cs @@ -16,6 +16,7 @@ internal static class StoryPointTests public static void PeopleAndChronology() { var point = Snapshot(); + Check(point.PovName == "Ari", "POV must resolve the timeline ID from the canonical character batch even when Timeline omits its display name."); Check(point.Characters.Select(x => x.Appearance.CharacterID).SequenceEqual([1, 2]), "Only selected SceneCharacters belong in presence."); Check(point.Characters[0].PreviousAppearance?.SceneID == 900, "Previous appearance must cross books and exclude selected scene, regardless of IDs."); Check(point.Characters[1].PreviousAppearance is null, "First appearance has no previous source."); @@ -125,7 +126,7 @@ internal static class StoryPointTests Books = [new Book { BookID = 2, BookNumber = 2, SortOrder = 2, BookTitle = "Voyage" }, new Book { BookID = 9, BookNumber = 1, SortOrder = 1, BookTitle = "Departure" }], Chapters = [new Chapter { ChapterID = 2, BookID = 2, ChapterNumber = 1, SortOrder = 1 }, new Chapter { ChapterID = 9, BookID = 9, ChapterNumber = 1, SortOrder = 1 }], Scenes = [new Scene { SceneID = 1, ChapterID = 2, SortOrder = 2, SceneNumber = 2, PrimaryLocationID = 5 }, - new Scene { SceneID = 20, ChapterID = 2, SortOrder = 1, SceneNumber = 1, PrimaryLocationID = 5, TimeModeName = "Exact Date", StartDateTime = new DateTime(2001, 2, 5) }, + new Scene { SceneID = 20, ChapterID = 2, POVCharacterID = 1, SortOrder = 1, SceneNumber = 1, PrimaryLocationID = 5, TimeModeName = "Exact Date", StartDateTime = new DateTime(2001, 2, 5) }, new Scene { SceneID = 900, ChapterID = 9, SortOrder = 1, SceneNumber = 1, PrimaryLocationID = 5 }] }, Characters = [new Character { CharacterID = 1, CharacterName = "Ari", BirthDate = new DateTime(1981, 1, 5), AgeAtSeriesStart = 19 }, new Character { CharacterID = 2, CharacterName = "Bo" }], Appearances = [new SceneCharacter { CharacterID = 1, CharacterName = "Ari", SceneID = 900, LocationID = 5, LocationPath = "Harbour" }, diff --git a/PlotLine/Services/StoryPointContextService.cs b/PlotLine/Services/StoryPointContextService.cs index f9e1d29..5939e1c 100644 --- a/PlotLine/Services/StoryPointContextService.cs +++ b/PlotLine/Services/StoryPointContextService.cs @@ -72,7 +72,7 @@ public sealed class StoryPointContextService(IProjectAccessService access, ITime var sources = ordered.Take(maximum + 1).ToDictionary(x => x.SceneID, x => { var ch = chaptersById[x.ChapterID]; - return new StoryPointSource(x.SceneID, $"{booksById[ch.BookID].BookDisplayTitle} · Ch {ch.ChapterNumber:g} · Scene {x.SceneNumber:g}"); + return new StoryPointSource(x.SceneID, $"{booksById[ch.BookID].BookDisplayTitle} · Ch {ch.ChapterNumber:0.##} · Scene {x.SceneNumber:0.##}"); }); var present = input.Appearances.Where(x => x.SceneID == sceneId).GroupBy(x => x.CharacterID) .Select(x => x.OrderByDescending(y => y.UpdatedDate).First()).OrderBy(x => x.CharacterName).ToList(); @@ -102,6 +102,7 @@ public sealed class StoryPointContextService(IProjectAccessService access, ITime return new StoryPointSnapshot { ProjectID = input.Timeline.Project.ProjectID, Book = book, Chapter = chapter, Scene = scene, Characters = people, + PovName = scene.POVCharacterID.HasValue ? characterModels.GetValueOrDefault(scene.POVCharacterID.Value)?.CharacterName ?? scene.POVCharacterName : scene.POVCharacterName, Relationships = RelationshipMapService.BuildAtScene(input.Relationships, indexes, sceneId) .Where(x => presentIds.Contains(x.CharacterAID) || presentIds.Contains(x.CharacterBID)).ToList(), Assets = pointAssets, Location = location, PreviousLocationUse = lastLocationScene is null ? null : sources[lastLocationScene.SceneID], diff --git a/PlotLine/ViewModels/StoryPointViewModel.cs b/PlotLine/ViewModels/StoryPointViewModel.cs index 2d365ca..87a7055 100644 --- a/PlotLine/ViewModels/StoryPointViewModel.cs +++ b/PlotLine/ViewModels/StoryPointViewModel.cs @@ -18,6 +18,7 @@ public sealed class StoryPointSnapshot public Book Book { get; init; } = new(); public Chapter Chapter { get; init; } = new(); public Scene Scene { get; init; } = new(); + public string? PovName { get; init; } public IReadOnlyList Characters { get; init; } = []; public IReadOnlyList Relationships { get; init; } = []; public IReadOnlyList Assets { get; init; } = []; diff --git a/PlotLine/Views/StoryPoint/Index.cshtml b/PlotLine/Views/StoryPoint/Index.cshtml index 19161db..45a09ee 100644 --- a/PlotLine/Views/StoryPoint/Index.cshtml +++ b/PlotLine/Views/StoryPoint/Index.cshtml @@ -18,10 +18,10 @@ @foreach (var book in Model.Books) { }
diff --git a/PlotLine/Views/StoryPoint/_Snapshot.cshtml b/PlotLine/Views/StoryPoint/_Snapshot.cshtml index 4adc74d..62de361 100644 --- a/PlotLine/Views/StoryPoint/_Snapshot.cshtml +++ b/PlotLine/Views/StoryPoint/_Snapshot.cshtml @@ -10,7 +10,7 @@ else var exactTime = scene.TimeModeName is "Exact DateTime" or "Exact Date and Time";
-

@Model.Book.BookDisplayTitle · Chapter @Model.Chapter.ChapterNumber.ToString("g") · Scene @scene.SceneNumber.ToString("g")

+

@Model.Book.BookDisplayTitle · Chapter @Model.Chapter.ChapterNumber.ToString("0.##") · Scene @scene.SceneNumber.ToString("0.##")

@scene.SceneTitle

@if (scene.StartDateTime.HasValue) @@ -21,7 +21,7 @@ else @if (!string.IsNullOrWhiteSpace(scene.RelativeTimeText)) { · @scene.RelativeTimeText } · @scene.TimeModeName

-

POV: @(scene.POVCharacterName ?? "Not recorded") · Location: @(Model.Location?.LocationPath ?? scene.PrimaryLocationName ?? "Not recorded")

+

POV: @(Model.PovName ?? "Not recorded") · Location: @(Model.Location?.LocationPath ?? scene.PrimaryLocationName ?? "Not recorded")

Open Scene Timeline @@ -33,7 +33,7 @@ else @if (Model.Characters.Count == 0) {

No characters attached to this scene.

} else {
@foreach (var person in Model.Characters) { + @string.Join(" · ", new[] { person.Appearance.RoleInSceneTypeName, person.Appearance.PresenceTypeName }.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase)) } diff --git a/PlotLine/wwwroot/css/story-point.css b/PlotLine/wwwroot/css/story-point.css index 3ef03b2..940909f 100644 --- a/PlotLine/wwwroot/css/story-point.css +++ b/PlotLine/wwwroot/css/story-point.css @@ -14,3 +14,7 @@ .story-point .table { --bs-table-bg: transparent; font-size: .9rem; } .story-point .table th { color: var(--plotline-text-muted, var(--bs-secondary-color)); font-size: .8rem; font-weight: 600; } @media (max-width: 800px) { .story-point-selectors { grid-template-columns: 1fr; } .story-point-grid { grid-template-columns: 1fr; } } +.story-point-header .btn-outline-primary, +.story-point-header .btn-outline-secondary { background: transparent; color: var(--plotline-text); border-color: var(--plotline-border-soft); } +.story-point-header .btn-outline-primary:hover, +.story-point-header .btn-outline-secondary:hover { background: var(--plotline-surface); color: var(--plotline-text); border-color: var(--plotline-accent); } diff --git a/docs/story-point.md b/docs/story-point.md index f414c04..b0bde8a 100644 --- a/docs/story-point.md +++ b/docs/story-point.md @@ -45,3 +45,9 @@ Read-only acceptance on project 4085 (The Alpha Flame), before publication: ## Deliberate limits Reader knowledge is deferred: a few relationship reader-awareness flags do not form a reliable general reader knowledge model. Word Companion automatic following is deferred; the service and partial are reusable for that integration. No AI briefing is included. Unanchored thread/relationship status and asset current-only fields cannot establish historical truth. Entity names and canonical authored facts are not versioned against later author edits; this feature reconstructs narrative event history, not database edit history. Undated chronology and incomplete locations/ages remain explicitly unknown. + +Deployed acceptance (7 September 2026): authenticated Chromium checked both scenes, navigation, chronology and ages, early versus later thread visibility, Scene/Timeline/Calendar links (all HTTP 200), dependent chapter/scene selection, AJAX responses, browser Back, no-JavaScript GET submission, invalid scene/project rejection (404), and anonymous login redirect (302). Calendar and Timeline continued to load. No browser exceptions or server error entries were observed. Visual review prompted duplicate participation-label removal, compact scene numbering, and scoped dark-theme action-link contrast. A direct SQL header check confirmed POV IDs even though Timeline omits their names; the composition now resolves Beth and Maggie from the already-loaded canonical character batch. + +The initial `/tmp` publish failed on tmpfs quota before installation. Publication succeeded from temporary staging on `/srv` using the same dotnet publish and physical release/backup/rsync/systemd process; production was untouched. The prior dev directory remains backed up. The first post-restart request briefly returned 502 during startup; subsequent home and authenticated checks returned 200. All 347 tests passed; the solution build and Release publish succeeded. The Word Companion README permission-description correction is included at the user's request. + +Independent SQL evidence confirmed Beth's DOB as 7 May 1965, Rosie's as 28 December 1964, and Sophie's approximate series-start age as 21. At narrative index 201, threads 26 and 29 had already resolved (indexes 144 and 99); 33, 35 and 36 had not yet been introduced (233, 261 and 287). The displayed active set was exactly 27, 28, 30, 31, 32 and 34. At index 1, only thread 34 had been introduced.
CharacterAge at this pointKnown locationPrevious appearance
@person.Appearance.CharacterName - @person.Appearance.RoleInSceneTypeName @person.Appearance.PresenceTypeName @(person.Age.DisplayText ?? "Unknown") @if (!person.Age.HasExactAge && person.Age.DisplayText is not null) { Approximate } @(person.State?.CurrentLocation ?? "Unknown")@(person.State?.State == "Inferred" ? "Last known" : "") @if (person.PreviousAppearance is { } previous) { @previous.Label } else { First recorded appearance }