Phase 11A PlotDirector Character Age and Story Date Management feature.

This commit is contained in:
Nick Beckley 2026-06-14 17:50:00 +01:00
parent cfe95a510f
commit b5decb0dc6
5 changed files with 121 additions and 13 deletions

View File

@ -0,0 +1,109 @@
using PlotLine.Models;
namespace PlotLine.Services;
public sealed class CharacterAgeDisplayResult
{
public bool HasExactAge { get; init; }
public string? Label { get; init; }
public string? DisplayText { get; init; }
}
public static class CharacterAgeDisplayService
{
public static CharacterAgeDisplayResult GetAgeForScene(SceneCharacter character, string? sceneTimeModeName, DateTime? sceneStartDateTime)
{
var effectiveDate = ResolveEffectiveSceneDate(sceneTimeModeName, sceneStartDateTime);
if (character.BirthDate.HasValue && effectiveDate.HasValue)
{
return new CharacterAgeDisplayResult
{
HasExactAge = true,
Label = "Age in scene",
DisplayText = FormatExactAge(character.BirthDate.Value.Date, effectiveDate.Value.Date)
};
}
if (character.AgeAtSeriesStart.HasValue)
{
return new CharacterAgeDisplayResult
{
Label = "Approximate age",
DisplayText = $"About {character.AgeAtSeriesStart.Value}"
};
}
return new CharacterAgeDisplayResult();
}
public static CharacterAgeDisplayResult GetAgeForScene(Character character, string? sceneTimeModeName, DateTime? sceneStartDateTime)
{
var effectiveDate = ResolveEffectiveSceneDate(sceneTimeModeName, sceneStartDateTime);
if (character.BirthDate.HasValue && effectiveDate.HasValue)
{
return new CharacterAgeDisplayResult
{
HasExactAge = true,
Label = "Age in scene",
DisplayText = FormatExactAge(character.BirthDate.Value.Date, effectiveDate.Value.Date)
};
}
if (character.AgeAtSeriesStart.HasValue)
{
return new CharacterAgeDisplayResult
{
Label = "Approximate age",
DisplayText = $"About {character.AgeAtSeriesStart.Value}"
};
}
return new CharacterAgeDisplayResult();
}
public static DateTime? ResolveEffectiveSceneDate(string? timeModeName, DateTime? startDateTime)
{
if (!startDateTime.HasValue)
{
return null;
}
return string.Equals(timeModeName, "Exact Date", StringComparison.OrdinalIgnoreCase)
|| string.Equals(timeModeName, "Exact DateTime", StringComparison.OrdinalIgnoreCase)
|| string.Equals(timeModeName, "Exact Date and Time", StringComparison.OrdinalIgnoreCase)
? startDateTime.Value.Date
: null;
}
public static string FormatExactAge(DateTime birthDate, DateTime sceneDate)
{
var years = sceneDate.Year - birthDate.Year;
if (sceneDate < birthDate.AddYears(years))
{
years--;
}
if (years < 0)
{
years = 0;
}
var lastBirthday = birthDate.AddYears(years);
var months = ((sceneDate.Year - lastBirthday.Year) * 12) + sceneDate.Month - lastBirthday.Month;
if (sceneDate.Day < lastBirthday.Day)
{
months--;
}
if (months < 0)
{
months = 0;
}
return months == 0
? $"{years} year{Pluralise(years)}"
: $"{years} year{Pluralise(years)}, {months} month{Pluralise(months)}";
}
private static string Pluralise(int value) => value == 1 ? string.Empty : "s";
}

View File

@ -1001,6 +1001,7 @@ public sealed class SceneService(
SceneNumber = existing.Count + 1,
RevisionStatusID = ChapterService.DefaultRevisionStatusId(lookupData),
TimeModeID = lookupData.TimeModes.FirstOrDefault(x => x.TimeModeName == "Unknown")?.TimeModeID ?? lookupData.TimeModes.First().TimeModeID,
TimeModeName = lookupData.TimeModes.FirstOrDefault(x => x.TimeModeName == "Unknown")?.TimeModeName ?? lookupData.TimeModes.First().TimeModeName,
TimeConfidenceID = lookupData.TimeConfidences.FirstOrDefault(x => x.TimeConfidenceName == "Unknown")?.TimeConfidenceID ?? lookupData.TimeConfidences.First().TimeConfidenceID,
Chapter = context.Value.Chapter,
Book = context.Value.Book,
@ -1031,6 +1032,7 @@ public sealed class SceneService(
SceneTitle = scene.SceneTitle,
Summary = scene.Summary,
TimeModeID = scene.TimeModeID,
TimeModeName = scene.TimeModeName,
StartDateTime = scene.StartDateTime,
EndDateTime = scene.EndDateTime,
DurationAmount = scene.DurationAmount,
@ -5614,20 +5616,9 @@ public sealed class CharacterService(
public string DisplayAge(Character character, DateTime? sceneStartDateTime = null)
{
if (character.BirthDate.HasValue && sceneStartDateTime.HasValue)
{
var age = sceneStartDateTime.Value.Year - character.BirthDate.Value.Year;
if (sceneStartDateTime.Value.Date < character.BirthDate.Value.Date.AddYears(age))
{
age--;
}
var age = CharacterAgeDisplayService.GetAgeForScene(character, sceneStartDateTime.HasValue ? "Exact DateTime" : null, sceneStartDateTime);
return age.ToString();
}
return character.AgeAtSeriesStart.HasValue
? $"About {character.AgeAtSeriesStart.Value}"
: "Unknown";
return age.DisplayText ?? "Unknown";
}
private static IReadOnlyList<SelectListItem> ToRelationshipTypeSelectList(IEnumerable<RelationshipType> relationshipTypes)

View File

@ -126,6 +126,8 @@ public sealed class SceneEditViewModel
[Display(Name = "Time mode")]
public int TimeModeID { get; set; }
public string TimeModeName { get; set; } = string.Empty;
[Display(Name = "Start date/time")]
public DateTime? StartDateTime { get; set; }

View File

@ -354,10 +354,15 @@
@foreach (var character in Model.SceneCharacters)
{
var editId = $"scene-character-edit-{character.SceneCharacterID}";
var ageDisplay = CharacterAgeDisplayService.GetAgeForScene(character, Model.TimeModeName, Model.StartDateTime);
<article class="character-appearance-card">
<div class="character-appearance-summary">
<div>
<strong>@character.CharacterName</strong>
@if (!string.IsNullOrWhiteSpace(ageDisplay.DisplayText))
{
<span class="muted">@ageDisplay.Label: @ageDisplay.DisplayText</span>
}
<span class="muted">@((character.RoleInSceneTypeName ?? "Role not set")) • @((character.PresenceTypeName ?? "Presence not set"))</span>
<span class="muted" title="@character.LocationPath">Location: @(character.LocationName ?? "Not set")</span>
<span class="muted">Entry: @(character.EntryLocationName ?? "Not set")</span>

View File

@ -1,5 +1,6 @@
@using PlotLine
@using PlotLine.Models
@using PlotLine.Services
@using PlotLine.ViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, PlotLine