Fix character profile age and date-only birth date editing

This commit is contained in:
Nick Beckley 2026-09-08 22:01:10 +00:00
parent e06bcc57fe
commit aa74df89e8
7 changed files with 133 additions and 11 deletions

View File

@ -0,0 +1,78 @@
using System.Reflection;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
internal static class CharacterProfileAgeTests
{
private static void Check(bool value, string message) { if (!value) throw new Exception(message); }
public static void ProfileUsesStoryDate()
{
var character = new Character { CharacterID = 1, BirthDate = new DateTime(1965, 9, 1) };
var first = new Scene { SceneID = 900, TimeModeName = "Exact Date", StartDateTime = new DateTime(1980, 5, 7) };
var later = new Scene { SceneID = 2, TimeModeName = "Exact Date", StartDateTime = new DateTime(1982, 12, 24) };
SceneCharacter[] appearances = [new() { CharacterID = 1, SceneID = 2 }, new() { CharacterID = 1, SceneID = 900 }];
var result = CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances);
Check(result.DisplayText == "14 years, 8 months" && result.ReferenceScene?.SceneID == 900, "Age must use first narrative dated appearance, not current date or minimum scene ID.");
character.AgeReferenceSceneID = 2;
result = CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances);
Check(result.DisplayText == "17 years, 3 months" && result.Context == "At age reference scene", "Explicit age reference must take precedence.");
character.AgeReferenceSceneID = null;
first.StartDateTime = null;
Check(CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances).ReferenceScene?.SceneID == 2, "Undated appearance must not invent a reference date.");
later.StartDateTime = null;
result = CharacterAgeDisplayService.GetProfileAge(character, [first, later], appearances);
Check(result.ReferenceScene is null && result.Context.Contains("dated story scene"), "Known DOB without chronology must explain why age is unavailable.");
character.AgeAtSeriesStart = 17;
Check(CharacterAgeDisplayService.GetProfileAge(character, [], []).DisplayText == "About 17", "Approximate fallback must stay available.");
character.BirthDate = null; character.AgeAtSeriesStart = null;
Check(CharacterAgeDisplayService.GetProfileAge(character, [], []).DisplayText == "Unknown", "Missing date/age must stay unknown.");
}
public static void DetailAndSaveUseCanonicalBirthDate()
{
var character = new Character { CharacterID = 1, ProjectID = 1, CharacterName = "Nora", BirthDate = new DateTime(1965, 9, 1), AgeReferenceSceneID = 900 };
var timeline = new TimelineData { Project = new Project { ProjectID = 1 }, Books = [new Book { BookID = 1 }], Chapters = [new Chapter { ChapterID = 1, BookID = 1 }],
Scenes = [new Scene { SceneID = 900, ChapterID = 1, TimeModeName = "Exact Date", StartDateTime = new DateTime(1980, 5, 7) }] };
Character? saved = null;
var repository = Proxy<ICharacterRepository>((method, args) => method switch {
"GetCharacterAsync" => Task.FromResult<Character?>(character),
"ListCharactersAsync" => Task.FromResult<IReadOnlyList<Character>>([character]),
"GetTimelineAsync" => Task.FromResult(((IReadOnlyList<Character>)[character], (IReadOnlyList<SceneCharacter>)[new SceneCharacter { CharacterID = 1, SceneID = 900 }])),
"GetLookupsAsync" => Task.FromResult(new CharacterLookupData()),
"SaveCharacterAsync" => Capture((Character)args![0]!),
_ => Default<ICharacterRepository>(method) });
Task<int> Capture(Character value) { saved = value; return Task.FromResult(1); }
var service = new CharacterService(
Proxy<IProjectRepository>((_, _) => Task.FromResult<Project?>(timeline.Project)),
Proxy<IBookRepository>(), repository, Proxy<IProjectCollaborationRepository>(), Proxy<ISubscriptionService>(),
Proxy<IProjectActivityService>(), Proxy<ICurrentUserService>(), Proxy<IVisualIdentityImageService>(), Proxy<ICharacterEnrichmentService>(),
Proxy<ITimelineRepository>((_, _) => Task.FromResult(timeline)));
var detail = service.GetCharacterDetailAsync(1).GetAwaiter().GetResult()!;
Check(detail.DisplayAge == "14 years, 8 months" && detail.AgeReferenceScene?.SceneID == 900, "Detail runtime must supply canonical chronology to age service.");
Check(detail.Character.BirthDate == character.BirthDate, "Detail must keep authored DOB independently of generated analysis.");
service.SaveCharacterAsync(new CharacterEditViewModel { CharacterID = 1, ProjectID = 1, CharacterName = "Nora", BirthDate = new DateTime(1965, 9, 1) }).GetAwaiter().GetResult();
Check(saved?.BirthDate == new DateTime(1965, 9, 1) && saved.AgeReferenceSceneID == 900, "Date-only save must preserve date and existing reference scene.");
service.SaveCharacterAsync(new CharacterEditViewModel { CharacterID = 1, ProjectID = 1, CharacterName = "Nora", BirthDate = null }).GetAwaiter().GetResult();
Check(saved?.BirthDate is null, "Birth date must remain optional and clearable.");
}
private static T Proxy<T>(Func<string, object?[]?, object?>? handler = null) where T : class
{
var result = DispatchProxy.Create<T, StoryPointProxy>();
((StoryPointProxy)(object)result).Handler = handler ?? ((method, _) => Default<T>(method));
return result;
}
private static object? Default<T>(string name)
{
var type = typeof(T).GetMethods().First(x => x.Name == name).ReturnType;
if (type == typeof(Task)) return Task.CompletedTask;
if (!type.IsGenericType || type.GetGenericTypeDefinition() != typeof(Task<>)) return type.IsValueType ? Activator.CreateInstance(type) : null;
var valueType = type.GenericTypeArguments[0];
object? value = valueType.IsGenericType && valueType.GetGenericTypeDefinition() == typeof(IReadOnlyList<>)
? Array.CreateInstance(valueType.GenericTypeArguments[0], 0)
: valueType.IsValueType ? Activator.CreateInstance(valueType) : null;
return typeof(Task).GetMethod(nameof(Task.FromResult))!.MakeGenericMethod(valueType).Invoke(null, [value]);
}
}

View File

@ -19,6 +19,8 @@ using PlotLine.ViewModels;
var tests = new (string Name, Action Test)[]
{
("Character profile age uses canonical reference chronology", CharacterProfileAgeTests.ProfileUsesStoryDate),
("Character detail and date-only save retain canonical DOB", CharacterProfileAgeTests.DetailAndSaveUseCanonicalBirthDate),
("Story Point presence, previous appearances and age cross book boundaries", StoryPointTests.PeopleAndChronology),
("Story Point knowledge stops at selected narrative position", StoryPointTests.KnowledgeBoundary),
("Story Point relationships and independent asset state are historical", StoryPointTests.RelationshipsAndAssets),

View File

@ -9,8 +9,29 @@ public sealed class CharacterAgeDisplayResult
public string? DisplayText { get; init; }
}
public sealed record CharacterProfileAgeDisplayResult(string DisplayText, string Context, Scene? ReferenceScene);
public static class CharacterAgeDisplayService
{
public static CharacterProfileAgeDisplayResult GetProfileAge(Character character,
IReadOnlyList<Scene> orderedScenes, IReadOnlyList<SceneCharacter> appearances)
{
var appearanceIds = appearances.Where(x => x.CharacterID == character.CharacterID).Select(x => x.SceneID).ToHashSet();
var reference = character.AgeReferenceSceneID.HasValue
? orderedScenes.FirstOrDefault(x => x.SceneID == character.AgeReferenceSceneID)
: null;
if (reference is null || !ResolveEffectiveSceneDate(reference.TimeModeName, reference.StartDateTime).HasValue)
reference = orderedScenes.FirstOrDefault(x => appearanceIds.Contains(x.SceneID)
&& ResolveEffectiveSceneDate(x.TimeModeName, x.StartDateTime).HasValue
&& (!character.BirthDate.HasValue || x.StartDateTime!.Value.Date >= character.BirthDate.Value.Date));
var age = GetAgeForScene(character, reference?.TimeModeName, reference?.StartDateTime);
return new CharacterProfileAgeDisplayResult(age.DisplayText ?? "Unknown",
age.HasExactAge ? (reference?.SceneID == character.AgeReferenceSceneID ? "At age reference scene" : "At first dated appearance")
: character.AgeAtSeriesStart.HasValue ? "Approximate age at series start"
: character.BirthDate.HasValue ? "A dated story scene is needed to calculate age." : "No birth date or approximate age recorded.",
age.HasExactAge ? reference : null);
}
public static CharacterAgeDisplayResult GetAgeForScene(SceneCharacter character, string? sceneTimeModeName, DateTime? sceneStartDateTime)
{
var effectiveDate = ResolveEffectiveSceneDate(sceneTimeModeName, sceneStartDateTime);

View File

@ -9537,7 +9537,8 @@ public sealed class CharacterService(
IProjectActivityService activity,
ICurrentUserService currentUser,
IVisualIdentityImageService visualIdentityImages,
ICharacterEnrichmentService characterEnrichment) : ICharacterService
ICharacterEnrichmentService characterEnrichment,
ITimelineRepository timelineRepository) : ICharacterService
{
public async Task<CharacterListViewModel?> GetCharactersAsync(int projectId)
{
@ -9617,6 +9618,14 @@ public sealed class CharacterService(
.GroupBy(x => x.CharacterRelationshipID)
.ToDictionary(x => x.Key, x => (IReadOnlyList<RelationshipEvent>)x.ToList());
var characterTimeline = await characters.GetTimelineAsync(project.ProjectID, null);
var timeline = await timelineRepository.GetByProjectAsync(project.ProjectID, null, false, [], false);
var orderedScenes = StoryStateService.OrderScenes(timeline).ToList();
var sceneIndexes = orderedScenes.Select((scene, index) => (scene.SceneID, index)).ToDictionary(x => x.SceneID, x => x.index);
var appearances = characterTimeline.Appearances.Where(x => x.CharacterID == character.CharacterID && sceneIndexes.ContainsKey(x.SceneID))
.OrderBy(x => sceneIndexes[x.SceneID]).ToList();
var profileAge = CharacterAgeDisplayService.GetProfileAge(character, orderedScenes, appearances);
return new CharacterDetailViewModel
{
Project = project,
@ -9624,8 +9633,10 @@ public sealed class CharacterService(
Aliases = await characters.ListAliasesAsync(character.CharacterID),
ProjectCharacters = projectCharacters,
Images = await characters.ListCharacterImagesAsync(character.CharacterID),
DisplayAge = DisplayAge(character),
Appearances = await GetAppearancesByCharacterAsync(character.CharacterID),
DisplayAge = profileAge.DisplayText,
AgeContext = profileAge.Context,
AgeReferenceScene = profileAge.ReferenceScene,
Appearances = appearances,
AttributeEvents = await characters.ListAttributeEventsByCharacterAsync(character.CharacterID),
KnowledgeItems = await characters.ListKnowledgeByCharacterAsync(character.CharacterID),
InitialRelationships = initialRelationships,
@ -9746,8 +9757,9 @@ public sealed class CharacterService(
ShortName = existing?.ShortName,
SexValueID = sex.SexValueID,
Sex = sex.SexName,
BirthDate = model.BirthDate,
BirthDate = model.BirthDate?.Date,
AgeAtSeriesStart = model.AgeAtSeriesStart,
AgeReferenceSceneID = existing?.AgeReferenceSceneID,
Height = model.Height,
EyeColour = model.EyeColour,
CharacterImportance = model.CharacterImportance,
@ -10205,9 +10217,5 @@ public sealed class CharacterService(
.ToList();
}
private async Task<IReadOnlyList<SceneCharacter>> GetAppearancesByCharacterAsync(int characterId)
{
var timeline = await characters.GetTimelineAsync((await characters.GetCharacterAsync(characterId))?.ProjectID ?? 0, null);
return timeline.Appearances.Where(x => x.CharacterID == characterId).ToList();
}
}

View File

@ -1922,6 +1922,7 @@ public sealed class CharacterEditViewModel
public string? CustomSex { get; set; }
[Display(Name = "Birth date")]
[DataType(DataType.Date)]
public DateTime? BirthDate { get; set; }
[Display(Name = "Age at series start")]
@ -1974,6 +1975,8 @@ public sealed class CharacterDetailViewModel
public IReadOnlyList<Character> ProjectCharacters { get; set; } = [];
public IReadOnlyList<CharacterImage> Images { get; set; } = [];
public string DisplayAge { get; set; } = "Unknown";
public string AgeContext { get; set; } = string.Empty;
public Scene? AgeReferenceScene { get; set; }
public IReadOnlyList<SceneCharacter> Appearances { get; set; } = [];
public IReadOnlyList<CharacterAttributeEvent> AttributeEvents { get; set; } = [];
public IReadOnlyList<CharacterKnowledgeItem> KnowledgeItems { get; set; } = [];

View File

@ -105,7 +105,15 @@
<h2>Profile</h2>
<p><strong>Preferred name:</strong> @Model.Character.CharacterName</p>
<p><strong>Sex:</strong> @Model.Character.Sex</p>
<p><strong>Age:</strong> @Model.DisplayAge</p>
<p><strong>Date of birth:</strong> <span data-character-birth-date>@(Model.Character.BirthDate?.ToString("d MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("en-GB")) ?? "Not recorded")</span></p>
<p><strong>Age:</strong> <span data-character-profile-age>@Model.DisplayAge</span>
<small class="d-block muted">@Model.AgeContext
@if (Model.AgeReferenceScene is { } ageScene)
{
<a asp-controller="Scenes" asp-action="Edit" asp-route-id="@ageScene.SceneID">@ageScene.SceneTitle · @ageScene.StartDateTime?.ToString("d MMMM yyyy", System.Globalization.CultureInfo.GetCultureInfo("en-GB"))</a>
}
</small>
</p>
<p><strong>Height:</strong> @Model.Character.Height</p>
<p><strong>Eye colour:</strong> @Model.Character.EyeColour</p>
<p><strong>Story importance:</strong> @storyImportance</p>

View File

@ -60,7 +60,9 @@
</div>
<div class="col-md-4">
<label asp-for="BirthDate" class="form-label"></label>
<input asp-for="BirthDate" class="form-control" />
<input asp-for="BirthDate" type="date" asp-format="{0:yyyy-MM-dd}" class="form-control" />
<span asp-validation-for="BirthDate" class="text-danger"></span>
<div class="form-text">Optional. Enter a date only; no birth time is needed.</div>
</div>
<div class="col-md-4">
<label asp-for="AgeAtSeriesStart" class="form-label"></label>