From ce0ec5655c78d769ae3a51e609cfda2baf7205f6 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 5 Sep 2026 22:07:32 +0000 Subject: [PATCH] Add story calendar view --- PlotLine.Tests/Program.cs | 166 ++++++++++ .../Controllers/StoryCalendarController.cs | 69 ++++ PlotLine/Program.cs | 1 + PlotLine/Services/CoreServices.cs | 223 +++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 81 +++++ .../Views/Shared/_ProjectSectionNav.cshtml | 1 + PlotLine/Views/StoryCalendar/Index.cshtml | 258 +++++++++++++++ PlotLine/wwwroot/css/site.css | 305 ++++++++++++++++++ PlotLine/wwwroot/css/site.min.css | 2 +- 9 files changed, 1105 insertions(+), 1 deletion(-) create mode 100644 PlotLine/Controllers/StoryCalendarController.cs create mode 100644 PlotLine/Views/StoryCalendar/Index.cshtml diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 2e6c3df..83cfd84 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -128,6 +128,11 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence temporal importer preserves manual chronology", StoryIntelligenceTemporalImporterPreservesManualChronology), ("Story Intelligence temporal materialisation saves canonical chronology", StoryIntelligenceTemporalMaterialisationSavesCanonicalChronology), ("Story Intelligence temporal malformed values are discarded", StoryIntelligenceTemporalMalformedValuesAreDiscarded), + ("Story Calendar places dated scenes on Monday-first month grid", StoryCalendarPlacesDatedScenesOnMondayFirstMonthGrid), + ("Story Calendar separates unplaced scenes without guessing", StoryCalendarSeparatesUnplacedScenesWithoutGuessing), + ("Story Calendar book filter and all books scope work", StoryCalendarBookFilterAndAllBooksScopeWork), + ("Story Calendar month navigation uses story dates across years", StoryCalendarMonthNavigationUsesStoryDatesAcrossYears), + ("Story Calendar handles no dated scenes", StoryCalendarHandlesNoDatedScenes), ("Story Intelligence structured metric values validate and import", StoryIntelligenceStructuredMetricValuesValidateAndImport), ("Story Intelligence structured schema covers complete response model", StoryIntelligenceStructuredSchemaCoversCompleteResponseModel), ("Story Intelligence structured schema remains fixture agnostic", StoryIntelligenceStructuredSchemaRemainsFixtureAgnostic), @@ -7363,6 +7368,64 @@ static void StoryIntelligenceTemporalMalformedValuesAreDiscarded() Assert(result.TimeConfidenceName == "Estimated", "Malformed confidence should not be treated as confirmed."); } +static void StoryCalendarPlacesDatedScenesOnMondayFirstMonthGrid() +{ + var service = new StoryCalendarService(new FakeTimelineRepository(BuildCalendarTimelineData())); + var model = service.GetAsync(1, 10, new DateOnly(1982, 12, 1), new DateOnly(1982, 12, 28)).GetAwaiter().GetResult()!; + var selectedDay = model.Weeks.SelectMany(week => week.Days).Single(day => day.Date == new DateOnly(1982, 12, 28)); + + Assert(model.SelectedMonth == new DateOnly(1982, 12, 1), "Requested December 1982 should be selected."); + Assert(model.Weeks.First().Days.First().Date.DayOfWeek == DayOfWeek.Monday, "Calendar weeks should start on Monday."); + Assert(selectedDay.DayLabel == "Tuesday, 28 December 1982", "Selected day should use UK date formatting and deterministic .NET weekday."); + Assert(selectedDay.Scenes.Count == 4, "Multiple scenes on 28 Dec 1982 should remain separate."); + Assert(selectedDay.Scenes.Select(scene => scene.SceneID).SequenceEqual([1001, 1002, 1003, 1004]), "Scenes on the same day should use exact time, time-of-day ordering, then narrative order."); +} + +static void StoryCalendarSeparatesUnplacedScenesWithoutGuessing() +{ + var service = new StoryCalendarService(new FakeTimelineRepository(BuildCalendarTimelineData())); + var model = service.GetAsync(1, 10, new DateOnly(1982, 12, 1), null).GetAwaiter().GetResult()!; + + Assert(model.UnplacedScenes.Count == 1, "StartDateTime null scenes should be listed as unplaced."); + Assert(model.UnplacedScenes[0].SceneID == 1005, "Unplaced scenes should not be put onto guessed calendar dates."); + Assert(!model.Weeks.SelectMany(week => week.Days).SelectMany(day => day.Scenes).Any(scene => scene.SceneID == 1005), "Unplaced scenes should not appear in any calendar cell."); +} + +static void StoryCalendarBookFilterAndAllBooksScopeWork() +{ + var data = BuildCalendarTimelineData(); + var allBooks = new StoryCalendarService(new FakeTimelineRepository(data)).GetAsync(1, null, new DateOnly(1982, 12, 1), new DateOnly(1982, 12, 28)).GetAwaiter().GetResult()!; + var bookOne = new StoryCalendarService(new FakeTimelineRepository(data)).GetAsync(1, 10, new DateOnly(1982, 12, 1), new DateOnly(1982, 12, 28)).GetAwaiter().GetResult()!; + + Assert(allBooks.DatedScenes.Any(scene => scene.BookID == 10) && allBooks.DatedScenes.Any(scene => scene.BookID == 20), "All Books should include dated scenes from multiple books."); + Assert(bookOne.DatedScenes.All(scene => scene.BookID == 10), "Book filter should exclude other books."); + Assert(bookOne.BookOptions.Any(option => option.Text == "All books"), "Book filter should include an All books option."); +} + +static void StoryCalendarMonthNavigationUsesStoryDatesAcrossYears() +{ + var service = new StoryCalendarService(new FakeTimelineRepository(BuildCalendarTimelineData())); + var december = service.GetAsync(1, null, null, null).GetAwaiter().GetResult()!; + var january = service.GetAsync(1, null, new DateOnly(1983, 1, 1), null).GetAwaiter().GetResult()!; + + Assert(december.SelectedMonth == new DateOnly(1982, 12, 1), "Earliest dated scene should determine the initial month."); + Assert(december.NextMonth == new DateOnly(1983, 1, 1), "Next month navigation should cross from December to January."); + Assert(january.PreviousMonth == new DateOnly(1982, 12, 1), "Previous month navigation should cross from January to December."); + Assert(december.NextStoryMonth == new DateOnly(1983, 1, 1), "Next story month should follow months that contain story scenes."); +} + +static void StoryCalendarHandlesNoDatedScenes() +{ + var data = BuildCalendarTimelineData(includeDatedScenes: false); + var service = new StoryCalendarService(new FakeTimelineRepository(data)); + var model = service.GetAsync(1, null, null, null).GetAwaiter().GetResult()!; + + Assert(model.SelectedMonth is null, "Calendar with no dated scenes should not default to the real current month."); + Assert(!model.HasDatedScenes, "No dated scenes should produce the empty state."); + Assert(model.StoryRangeLabel == "No dated scenes", "Empty calendar should have a useful range label."); + Assert(model.UnplacedScenes.Count == 1, "Undated scenes should still be visible as unplaced."); +} + static void StoryIntelligenceStructuredMetricValuesValidateAndImport() { var validator = new StorySceneValidator(); @@ -7505,6 +7568,81 @@ static SceneIntelligenceScene SceneWithoutTemporalExtraction() TimelineClues = [] }; +static TimelineData BuildCalendarTimelineData(bool includeDatedScenes = true) +{ + var project = new Project { ProjectID = 1, ProjectName = "Calendar Test Project" }; + var bookOne = new Book { ProjectID = 1, BookID = 10, BookNumber = 1, BookTitle = "The Alpha Flame", SortOrder = 10 }; + var bookTwo = new Book { ProjectID = 1, BookID = 20, BookNumber = 2, BookTitle = "The Beta Flame", SortOrder = 20 }; + var chapterOne = new Chapter { BookID = 10, ChapterID = 101, ChapterNumber = 6, ChapterTitle = "Danger in the Dark", SortOrder = 10 }; + var chapterTwo = new Chapter { BookID = 20, ChapterID = 201, ChapterNumber = 1, ChapterTitle = "New Year", SortOrder = 10 }; + var scenes = new List + { + new() + { + SceneID = 1005, + BookID = 10, + BookNumber = 1, + BookTitle = "The Alpha Flame", + ChapterID = 101, + ChapterNumber = 6, + SceneNumber = 5, + SceneTitle = "Unplaced scene", + TimeModeName = "Unknown", + TimeConfidenceName = "Unknown", + StartDateTime = null + } + }; + + if (includeDatedScenes) + { + scenes.InsertRange(0, + [ + CalendarScene(1001, 10, 1, "The Alpha Flame", 101, 6, 1, "Morning call", new DateTime(1982, 12, 28), "28 Dec 1982, morning"), + CalendarScene(1002, 10, 1, "The Alpha Flame", 101, 6, 2, "Pub opens", new DateTime(1982, 12, 28, 20, 15, 0), "28 Dec 1982, evening", "Exact DateTime"), + CalendarScene(1003, 10, 1, "The Alpha Flame", 101, 6, 3, "Dance floor", new DateTime(1982, 12, 28), "28 Dec 1982, night"), + CalendarScene(1004, 10, 1, "The Alpha Flame", 101, 6, 4, "Aftermath", new DateTime(1982, 12, 28), "28 Dec 1982, night"), + CalendarScene(2001, 20, 2, "The Beta Flame", 201, 1, 1, "January opening", new DateTime(1983, 1, 2), "02 Jan 1983, morning") + ]); + } + + return new TimelineData + { + Project = project, + Books = [bookOne, bookTwo], + Chapters = [chapterOne, chapterTwo], + Scenes = scenes + }; +} + +static Scene CalendarScene( + int sceneId, + int bookId, + int bookNumber, + string bookTitle, + int chapterId, + decimal chapterNumber, + decimal sceneNumber, + string title, + DateTime startDateTime, + string relativeTimeText, + string timeModeName = "Exact Date") + => new() + { + SceneID = sceneId, + BookID = bookId, + BookNumber = bookNumber, + BookTitle = bookTitle, + ChapterID = chapterId, + ChapterNumber = chapterNumber, + SceneNumber = sceneNumber, + SceneTitle = title, + Summary = $"{title} summary.", + StartDateTime = startDateTime, + TimeModeName = timeModeName, + RelativeTimeText = relativeTimeText, + TimeConfidenceName = "Implied" + }; + static JsonElement SceneSchemaRoot() => JsonSerializer.SerializeToElement(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema); @@ -7927,3 +8065,31 @@ internal sealed class FakeManuscriptTextStatisticsRepository(string sourceText) SourceText = sourceText }); } + +internal sealed class FakeTimelineRepository(TimelineData data) : ITimelineRepository +{ + public Task GetByProjectAsync(int projectId, int? bookId, bool includeMetrics, IEnumerable metricTypeIds, bool includePlotLines) + { + var filtered = new TimelineData + { + Project = data.Project?.ProjectID == projectId ? data.Project : null, + Books = data.Books.Where(book => book.ProjectID == projectId).ToList(), + Chapters = data.Chapters + .Where(chapter => !bookId.HasValue || chapter.BookID == bookId.Value) + .ToList(), + Scenes = data.Scenes + .Where(scene => !bookId.HasValue || scene.BookID == bookId.Value) + .ToList(), + PurposeLabels = data.PurposeLabels, + MetricTypes = includeMetrics ? data.MetricTypes : [], + MetricPoints = includeMetrics ? data.MetricPoints : [], + PlotLines = includePlotLines ? data.PlotLines : [], + ThreadEvents = includePlotLines ? data.ThreadEvents : [] + }; + + return Task.FromResult(filtered); + } + + public Task> GetSceneOverviewsAsync(int projectId, int? bookId) + => Task.FromResult>([]); +} diff --git a/PlotLine/Controllers/StoryCalendarController.cs b/PlotLine/Controllers/StoryCalendarController.cs new file mode 100644 index 0000000..c61463d --- /dev/null +++ b/PlotLine/Controllers/StoryCalendarController.cs @@ -0,0 +1,69 @@ +using System.Globalization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PlotLine.Services; + +namespace PlotLine.Controllers; + +[Authorize] +public sealed class StoryCalendarController(IStoryCalendarService calendar) : Controller +{ + public async Task Index(int projectId, int? bookId, string? month, string? date) + { + if (projectId == 0) + { + return BadRequest("ProjectID is required."); + } + + var selectedMonth = ParseMonth(month); + var selectedDate = ParseDate(date); + var model = await calendar.GetAsync(projectId, bookId, selectedMonth, selectedDate); + return model is null ? NotFound() : View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public IActionResult ApplyFilters(int projectId, int? bookId, string? month) + { + return RedirectToAction(nameof(Index), new + { + projectId, + bookId, + month + }); + } + + private static DateOnly? ParseMonth(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return DateOnly.TryParseExact( + $"{value.Trim()}-01", + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var parsed) + ? new DateOnly(parsed.Year, parsed.Month, 1) + : null; + } + + private static DateOnly? ParseDate(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return DateOnly.TryParseExact( + value.Trim(), + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var parsed) + ? parsed + : null; + } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 590dc5b..881415c 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -175,6 +175,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index d43353a..394db59 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Globalization; using System.Net; using System.Text; using System.Text.Json; @@ -312,6 +313,228 @@ public interface ITimelineService Task AddLocationRelationshipByDropAsync(TimelineLocationRelationshipDropViewModel model); } +public interface IStoryCalendarService +{ + Task GetAsync(int projectId, int? bookId, DateOnly? month, DateOnly? selectedDate); +} + +public sealed class StoryCalendarService(ITimelineRepository timelineRepository) : IStoryCalendarService +{ + private static readonly CultureInfo UkCulture = CultureInfo.GetCultureInfo("en-GB"); + + public async Task GetAsync(int projectId, int? bookId, DateOnly? month, DateOnly? selectedDate) + { + var timeline = await timelineRepository.GetByProjectAsync(projectId, bookId, includeMetrics: false, metricTypeIds: [], includePlotLines: false); + if (timeline.Project is null) + { + return null; + } + + var chaptersById = timeline.Chapters.ToDictionary(chapter => chapter.ChapterID); + var booksById = timeline.Books.ToDictionary(book => book.BookID); + var calendarScenes = BuildSceneViewModels(timeline.Scenes, chaptersById, booksById); + var datedScenes = calendarScenes + .Where(scene => scene.StoryDate.HasValue) + .OrderBy(scene => scene.StoryDate) + .ThenBy(scene => scene.TimeOfDayRank) + .ThenBy(scene => scene.StartDateTime?.TimeOfDay ?? TimeSpan.Zero) + .ThenBy(scene => scene.NarrativeOrder) + .ToList(); + var unplacedScenes = calendarScenes + .Where(scene => !scene.StoryDate.HasValue) + .OrderBy(scene => scene.NarrativeOrder) + .ToList(); + + var earliest = datedScenes.Select(scene => scene.StoryDate!.Value).DefaultIfEmpty().Min(); + var latest = datedScenes.Select(scene => scene.StoryDate!.Value).DefaultIfEmpty().Max(); + var hasDatedScenes = datedScenes.Count > 0; + DateOnly? earliestDate = hasDatedScenes ? earliest : null; + DateOnly? latestDate = hasDatedScenes ? latest : null; + var selectedMonth = ResolveInitialMonth(month, earliestDate); + var selectedStoryDate = selectedDate ?? selectedMonth; + var weeks = selectedMonth.HasValue + ? BuildCalendarWeeks(selectedMonth.Value, datedScenes, selectedStoryDate) + : []; + var selectedDay = selectedStoryDate.HasValue + ? weeks.SelectMany(week => week.Days).FirstOrDefault(day => day.Date == selectedStoryDate.Value) + : null; + if (selectedDay is null && selectedStoryDate.HasValue) + { + selectedDay = new StoryCalendarDayViewModel + { + Date = selectedStoryDate.Value, + IsInSelectedMonth = selectedMonth.HasValue && selectedStoryDate.Value.Year == selectedMonth.Value.Year && selectedStoryDate.Value.Month == selectedMonth.Value.Month, + IsSelected = true, + Scenes = datedScenes.Where(scene => scene.StoryDate == selectedStoryDate.Value).ToList() + }; + } + + return new StoryCalendarViewModel + { + Project = timeline.Project, + SelectedBookID = bookId, + SelectedMonth = selectedMonth, + SelectedDate = selectedStoryDate, + EarliestStoryDate = earliestDate, + LatestStoryDate = latestDate, + PreviousMonth = selectedMonth?.AddMonths(-1), + NextMonth = selectedMonth?.AddMonths(1), + PreviousStoryMonth = selectedMonth.HasValue ? AdjacentStoryMonth(datedScenes, selectedMonth.Value, previous: true) : null, + NextStoryMonth = selectedMonth.HasValue ? AdjacentStoryMonth(datedScenes, selectedMonth.Value, previous: false) : null, + FirstStoryMonth = earliestDate.HasValue ? FirstOfMonth(earliestDate.Value) : null, + BookOptions = BuildBookOptions(timeline.Books, bookId), + MonthOptions = BuildMonthOptions(earliestDate, latestDate, selectedMonth), + Weeks = weeks, + DatedScenes = datedScenes, + UnplacedScenes = unplacedScenes, + SelectedDay = selectedDay + }; + } + + public static DateOnly? ResolveInitialMonth(DateOnly? requestedMonth, DateOnly? earliestStoryDate) + => requestedMonth.HasValue + ? FirstOfMonth(requestedMonth.Value) + : earliestStoryDate.HasValue ? FirstOfMonth(earliestStoryDate.Value) : null; + + public static IReadOnlyList BuildCalendarWeeks( + DateOnly selectedMonth, + IReadOnlyList datedScenes, + DateOnly? selectedDate = null) + { + var firstOfMonth = FirstOfMonth(selectedMonth); + var daysBefore = ((int)firstOfMonth.DayOfWeek + 6) % 7; + var gridStart = firstOfMonth.AddDays(-daysBefore); + var lastOfMonth = firstOfMonth.AddMonths(1).AddDays(-1); + var daysAfter = (7 - (((int)lastOfMonth.DayOfWeek + 6) % 7) - 1) % 7; + var totalDays = daysBefore + DateTime.DaysInMonth(selectedMonth.Year, selectedMonth.Month) + daysAfter; + var scenesByDate = datedScenes + .Where(scene => scene.StoryDate.HasValue) + .GroupBy(scene => scene.StoryDate!.Value) + .ToDictionary(group => group.Key, group => (IReadOnlyList)group + .OrderBy(scene => scene.TimeOfDayRank) + .ThenBy(scene => scene.StartDateTime?.TimeOfDay ?? TimeSpan.Zero) + .ThenBy(scene => scene.NarrativeOrder) + .ToList()); + + var weeks = new List(); + for (var offset = 0; offset < totalDays; offset += 7) + { + var days = new List(); + for (var index = 0; index < 7; index++) + { + var date = gridStart.AddDays(offset + index); + days.Add(new StoryCalendarDayViewModel + { + Date = date, + IsInSelectedMonth = date.Year == selectedMonth.Year && date.Month == selectedMonth.Month, + IsSelected = selectedDate.HasValue && date == selectedDate.Value, + Scenes = scenesByDate.GetValueOrDefault(date, []) + }); + } + + weeks.Add(new StoryCalendarWeekViewModel { Days = days }); + } + + return weeks; + } + + public static int TimeOfDayRank(Scene scene) + { + var label = $"{scene.RelativeTimeText} {scene.TimeModeName}".ToLowerInvariant(); + if (label.Contains("dawn")) return 100; + if (label.Contains("morning")) return 200; + if (label.Contains("midday") || label.Contains("lunch")) return 300; + if (label.Contains("afternoon")) return 400; + if (label.Contains("evening")) return 500; + if (label.Contains("night") || label.Contains("midnight")) return 600; + return 700; + } + + private static IReadOnlyList BuildSceneViewModels( + IReadOnlyList scenes, + IReadOnlyDictionary chaptersById, + IReadOnlyDictionary booksById) + { + return scenes + .Where(scene => !scene.IsArchived) + .Select((scene, index) => + { + chaptersById.TryGetValue(scene.ChapterID, out var chapter); + booksById.TryGetValue(chapter?.BookID ?? scene.BookID, out var book); + return new StoryCalendarSceneViewModel + { + SceneID = scene.SceneID, + BookID = book?.BookID ?? scene.BookID, + BookTitle = book?.BookDisplayTitle ?? scene.BookDisplayTitle, + BookNumber = book?.BookNumber ?? scene.BookNumber, + ChapterID = scene.ChapterID, + ChapterNumber = chapter?.ChapterNumber ?? scene.ChapterNumber, + ChapterTitle = chapter?.ChapterTitle ?? scene.ChapterTitle ?? string.Empty, + SceneNumber = scene.SceneNumber, + Title = string.IsNullOrWhiteSpace(scene.SceneTitle) ? $"Scene {scene.SceneNumber:g}" : scene.SceneTitle, + Pov = scene.POVCharacterName, + StartDateTime = scene.StartDateTime, + TimeMode = scene.TimeModeName, + TimeConfidence = scene.TimeConfidenceName, + RelativeTimeText = scene.RelativeTimeText, + Summary = scene.Summary ?? scene.StructuralSummary, + NarrativeOrder = index, + StoryDate = scene.StartDateTime.HasValue ? DateOnly.FromDateTime(scene.StartDateTime.Value) : null, + TimeOfDayRank = TimeOfDayRank(scene), + TimeLabel = scene.TimeLabel + }; + }) + .ToList(); + } + + private static IReadOnlyList BuildBookOptions(IReadOnlyList books, int? selectedBookId) + { + var items = new List + { + new("All books", string.Empty, !selectedBookId.HasValue) + }; + items.AddRange(books + .OrderBy(book => book.SortOrder) + .ThenBy(book => book.BookNumber) + .Select(book => new SelectListItem($"Book {book.BookNumber}: {book.BookDisplayTitle}", book.BookID.ToString(CultureInfo.InvariantCulture), book.BookID == selectedBookId))); + return items; + } + + private static IReadOnlyList BuildMonthOptions(DateOnly? earliest, DateOnly? latest, DateOnly? selectedMonth) + { + if (!earliest.HasValue || !latest.HasValue) + { + return []; + } + + var options = new List(); + for (var month = FirstOfMonth(earliest.Value); month <= FirstOfMonth(latest.Value); month = month.AddMonths(1)) + { + var value = month.ToString("yyyy-MM", CultureInfo.InvariantCulture); + options.Add(new SelectListItem(month.ToString("MMMM yyyy", UkCulture), value, selectedMonth == month)); + } + + return options; + } + + private static DateOnly? AdjacentStoryMonth(IReadOnlyList datedScenes, DateOnly selectedMonth, bool previous) + { + var months = datedScenes + .Where(scene => scene.StoryDate.HasValue) + .Select(scene => FirstOfMonth(scene.StoryDate!.Value)) + .Distinct() + .OrderBy(date => date) + .ToList(); + var adjacent = previous + ? months.Where(date => date < selectedMonth).Cast().LastOrDefault() + : months.Where(date => date > selectedMonth).Cast().FirstOrDefault(); + return adjacent; + } + + private static DateOnly FirstOfMonth(DateOnly date) + => new(date.Year, date.Month, 1); +} + public interface ISceneMetricTypeService { Task GetAsync(int projectId); diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 7ae8449..92c5abb 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.Globalization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using PlotLine.Models; @@ -1253,6 +1254,86 @@ public sealed class TimelineViewModel public ContinuityValidationSummary? LastValidationSummary { get; set; } } +public sealed class StoryCalendarViewModel +{ + public Project Project { get; set; } = new(); + public int? SelectedBookID { get; set; } + public DateOnly? SelectedMonth { get; set; } + public DateOnly? SelectedDate { get; set; } + public DateOnly? EarliestStoryDate { get; set; } + public DateOnly? LatestStoryDate { get; set; } + public DateOnly? PreviousMonth { get; set; } + public DateOnly? NextMonth { get; set; } + public DateOnly? PreviousStoryMonth { get; set; } + public DateOnly? NextStoryMonth { get; set; } + public DateOnly? FirstStoryMonth { get; set; } + public IReadOnlyList BookOptions { get; set; } = []; + public IReadOnlyList MonthOptions { get; set; } = []; + public IReadOnlyList Weeks { get; set; } = []; + public IReadOnlyList DatedScenes { get; set; } = []; + public IReadOnlyList UnplacedScenes { get; set; } = []; + public StoryCalendarDayViewModel? SelectedDay { get; set; } + public int SceneCount => DatedScenes.Count; + public int ActiveStoryDayCount => DatedScenes.Select(scene => scene.StoryDate).Distinct().Count(); + public bool HasDatedScenes => DatedScenes.Count > 0; + public string SelectedMonthLabel => SelectedMonth?.ToString("MMMM yyyy", CultureInfo.GetCultureInfo("en-GB")) ?? "No dated scenes"; + public string StoryRangeLabel + { + get + { + if (EarliestStoryDate.HasValue && LatestStoryDate.HasValue) + { + return EarliestStoryDate == LatestStoryDate + ? EarliestStoryDate.Value.ToString("d MMMM yyyy", CultureInfo.GetCultureInfo("en-GB")) + : $"{EarliestStoryDate.Value.ToString("d MMMM yyyy", CultureInfo.GetCultureInfo("en-GB"))} - {LatestStoryDate.Value.ToString("d MMMM yyyy", CultureInfo.GetCultureInfo("en-GB"))}"; + } + + return "No dated scenes"; + } + } +} + +public sealed class StoryCalendarWeekViewModel +{ + public IReadOnlyList Days { get; set; } = []; +} + +public sealed class StoryCalendarDayViewModel +{ + public DateOnly Date { get; set; } + public int DayNumber => Date.Day; + public bool IsInSelectedMonth { get; set; } + public bool IsSelected { get; set; } + public IReadOnlyList Scenes { get; set; } = []; + public string DayLabel => Date.ToString("dddd, d MMMM yyyy", CultureInfo.GetCultureInfo("en-GB")); + public string QueryValue => Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); +} + +public sealed class StoryCalendarSceneViewModel +{ + public int SceneID { get; set; } + public int BookID { get; set; } + public string BookTitle { get; set; } = string.Empty; + public int BookNumber { get; set; } + public int ChapterID { get; set; } + public decimal ChapterNumber { get; set; } + public string ChapterTitle { get; set; } = string.Empty; + public decimal SceneNumber { get; set; } + public string Title { get; set; } = string.Empty; + public string? Pov { get; set; } + public DateTime? StartDateTime { get; set; } + public string TimeMode { get; set; } = string.Empty; + public string TimeConfidence { get; set; } = string.Empty; + public string? RelativeTimeText { get; set; } + public string? Summary { get; set; } + public int NarrativeOrder { get; set; } + public DateOnly? StoryDate { get; set; } + public int TimeOfDayRank { get; set; } + public string TimeLabel { get; set; } = string.Empty; + public string SceneLabel => $"Ch {ChapterNumber:g} · Scene {SceneNumber:g}"; + public string BookLabel => BookNumber > 0 ? $"Book {BookNumber}" : BookTitle; +} + public sealed class TimelineSettingsViewModel { public int ProjectID { get; set; } diff --git a/PlotLine/Views/Shared/_ProjectSectionNav.cshtml b/PlotLine/Views/Shared/_ProjectSectionNav.cshtml index 4b75d63..eeedd33 100644 --- a/PlotLine/Views/Shared/_ProjectSectionNav.cshtml +++ b/PlotLine/Views/Shared/_ProjectSectionNav.cshtml @@ -12,6 +12,7 @@