diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 83cfd84..1d10751 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Net; using System.Text; using System.IO.Compression; +using System.Globalization; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -132,7 +133,13 @@ var tests = new (string Name, Action Test)[] ("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 month dropdown follows displayed month", StoryCalendarMonthDropdownFollowsDisplayedMonth), ("Story Calendar handles no dated scenes", StoryCalendarHandlesNoDatedScenes), + ("Story Calendar day details endpoint returns partial", StoryCalendarDayDetailsEndpointReturnsPartial), + ("Story Calendar day details endpoint respects book filter", StoryCalendarDayDetailsEndpointRespectsBookFilter), + ("Story Calendar day details endpoint handles empty story date", StoryCalendarDayDetailsEndpointHandlesEmptyStoryDate), + ("Story Calendar day details endpoint rejects invalid project", StoryCalendarDayDetailsEndpointRejectsInvalidProject), + ("Story Calendar day selection uses progressive AJAX hooks", StoryCalendarDaySelectionUsesProgressiveAjaxHooks), ("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), @@ -7414,6 +7421,22 @@ static void StoryCalendarMonthNavigationUsesStoryDatesAcrossYears() Assert(december.NextStoryMonth == new DateOnly(1983, 1, 1), "Next story month should follow months that contain story scenes."); } +static void StoryCalendarMonthDropdownFollowsDisplayedMonth() +{ + var service = new StoryCalendarService(new FakeTimelineRepository(BuildCalendarTimelineData())); + var firstDetected = service.GetAsync(1, null, new DateOnly(1982, 12, 1), null).GetAwaiter().GetResult()!; + var beforeDetected = service.GetAsync(1, null, new DateOnly(1982, 11, 1), null).GetAwaiter().GetResult()!; + var afterDetected = service.GetAsync(1, null, new DateOnly(1983, 2, 1), null).GetAwaiter().GetResult()!; + + Assert(firstDetected.MonthOptions.Single(option => option.Value == "1982-12").Selected, "A detected selected month should be selected normally."); + Assert(beforeDetected.MonthOptions.First().Value == "1982-11", "A selected month before the detected range should be injected at the start of the dropdown."); + Assert(beforeDetected.MonthOptions.Single(option => option.Value == "1982-11").Selected, "The injected earlier month should be selected."); + Assert(afterDetected.MonthOptions.Last().Value == "1983-02", "A selected month after the detected range should be injected at the end of the dropdown."); + Assert(afterDetected.MonthOptions.Single(option => option.Value == "1983-02").Selected, "The injected later month should be selected."); + Assert(beforeDetected.MonthOptions.Single(option => option.Selected).Value == beforeDetected.SelectedMonth!.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture), "The dropdown selected option must match the rendered month."); + Assert(afterDetected.MonthOptions.Single(option => option.Selected).Value == afterDetected.SelectedMonth!.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture), "The dropdown selected option must match the rendered month after the detected range."); +} + static void StoryCalendarHandlesNoDatedScenes() { var data = BuildCalendarTimelineData(includeDatedScenes: false); @@ -7426,6 +7449,63 @@ static void StoryCalendarHandlesNoDatedScenes() Assert(model.UnplacedScenes.Count == 1, "Undated scenes should still be visible as unplaced."); } +static void StoryCalendarDayDetailsEndpointReturnsPartial() +{ + var controller = CreateStoryCalendarController(BuildCalendarTimelineData()); + var result = controller.DayDetails(1, null, "1982-12-28").GetAwaiter().GetResult(); + var partial = AssertIs(result, "Valid day details request should return a partial view."); + var model = AssertIs(partial.Model!, "Day details partial should receive the focused day details model."); + + Assert(partial.ViewName == "_StoryCalendarDayDetails", "Day details endpoint should render the calendar day details partial."); + Assert(model.SelectedDay?.Date == new DateOnly(1982, 12, 28), "Day details should render the requested date."); + Assert(model.SelectedDay?.Scenes.Count == 4, "Day details should include scenes on the requested date."); +} + +static void StoryCalendarDayDetailsEndpointRespectsBookFilter() +{ + var allBooksController = CreateStoryCalendarController(BuildCalendarTimelineData()); + var bookOneController = CreateStoryCalendarController(BuildCalendarTimelineData()); + var allBooks = AssertIs(allBooksController.DayDetails(1, null, "1983-01-02").GetAwaiter().GetResult(), "All-books day details should return a partial."); + var bookOne = AssertIs(bookOneController.DayDetails(1, 10, "1983-01-02").GetAwaiter().GetResult(), "Book-filtered day details should return a partial."); + var allBooksModel = AssertIs(allBooks.Model!, "All-books partial should use day details model."); + var bookOneModel = AssertIs(bookOne.Model!, "Book-filtered partial should use day details model."); + + Assert(allBooksModel.SelectedDay?.Scenes.Any(scene => scene.BookID == 20) == true, $"All books should include scenes from the second book on the selected date. Date: {allBooksModel.SelectedDay?.QueryValue ?? "none"}; scenes: {string.Join(", ", allBooksModel.SelectedDay?.Scenes.Select(scene => $"{scene.SceneID}:{scene.BookID}") ?? [])}"); + Assert(bookOneModel.SelectedDay?.Scenes.Any() == false, "Book filter should exclude scenes outside the selected book."); +} + +static void StoryCalendarDayDetailsEndpointHandlesEmptyStoryDate() +{ + var controller = CreateStoryCalendarController(BuildCalendarTimelineData()); + var partial = AssertIs(controller.DayDetails(1, null, "1982-12-29").GetAwaiter().GetResult(), "Empty day details should still return a partial."); + var model = AssertIs(partial.Model!, "Empty day partial should use day details model."); + + Assert(model.SelectedDay?.Date == new DateOnly(1982, 12, 29), "Empty day details should preserve the requested date."); + Assert(model.SelectedDay?.Scenes.Count == 0, "Empty day details should have no scenes."); +} + +static void StoryCalendarDayDetailsEndpointRejectsInvalidProject() +{ + var controller = CreateStoryCalendarController(BuildCalendarTimelineData()); + var result = controller.DayDetails(999, null, "1982-12-28").GetAwaiter().GetResult(); + + Assert(result is NotFoundResult, "Invalid project should follow the main calendar security/access path and return NotFound."); + Assert(typeof(StoryCalendarController).GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: false).Any(), "Story Calendar endpoints should remain authorization protected."); +} + +static void StoryCalendarDaySelectionUsesProgressiveAjaxHooks() +{ + var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/StoryCalendar/Index.cshtml")); + var script = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/wwwroot/js/site.js")); + + Assert(view.Contains("data-story-calendar-day", StringComparison.Ordinal), "Calendar date links should expose a progressive enhancement hook."); + Assert(view.Contains("asp-action=\"Index\"", StringComparison.Ordinal), "Calendar date links should keep their full-page fallback href."); + Assert(view.Contains("data-story-calendar-details-url", StringComparison.Ordinal), "Calendar date links should include the day-details endpoint URL."); + Assert(script.Contains("fetch(detailsUrl", StringComparison.Ordinal), "Calendar date clicks should load day details asynchronously."); + Assert(script.Contains("window.history.pushState", StringComparison.Ordinal), "Calendar date clicks should preserve selected-date URL state."); + Assert(script.Contains("is-selected", StringComparison.Ordinal), "Calendar date clicks should update selected-day styling."); +} + static void StoryIntelligenceStructuredMetricValuesValidateAndImport() { var validator = new StorySceneValidator(); @@ -7614,6 +7694,9 @@ static TimelineData BuildCalendarTimelineData(bool includeDatedScenes = true) }; } +static StoryCalendarController CreateStoryCalendarController(TimelineData data) + => new(new StoryCalendarService(new FakeTimelineRepository(data)), NullLogger.Instance); + static Scene CalendarScene( int sceneId, int bookId, @@ -7786,6 +7869,16 @@ static void Assert(bool condition, string message) } } +static T AssertIs(object value, string message) +{ + if (value is T typed) + { + return typed; + } + + throw new InvalidOperationException(message); +} + static void AssertThrows(Action action, string message) { try diff --git a/PlotLine/Controllers/StoryCalendarController.cs b/PlotLine/Controllers/StoryCalendarController.cs index c61463d..3409333 100644 --- a/PlotLine/Controllers/StoryCalendarController.cs +++ b/PlotLine/Controllers/StoryCalendarController.cs @@ -1,12 +1,14 @@ using System.Globalization; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using PlotLine.Services; namespace PlotLine.Controllers; [Authorize] -public sealed class StoryCalendarController(IStoryCalendarService calendar) : Controller +public sealed class StoryCalendarController(IStoryCalendarService calendar, ILogger logger) : Controller { public async Task Index(int projectId, int? bookId, string? month, string? date) { @@ -21,6 +23,32 @@ public sealed class StoryCalendarController(IStoryCalendarService calendar) : Co return model is null ? NotFound() : View(model); } + [HttpGet] + public async Task DayDetails(int projectId, int? bookId, string? date) + { + if (projectId == 0) + { + return BadRequest("ProjectID is required."); + } + + var selectedDate = ParseDate(date); + if (!selectedDate.HasValue) + { + return BadRequest("Date is required."); + } + + try + { + var model = await calendar.GetAsync(projectId, bookId, FirstOfMonth(selectedDate.Value), selectedDate); + return model is null ? NotFound() : PartialView("_StoryCalendarDayDetails", model.DayDetails); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load Story Calendar day details for project {ProjectID} and date {StoryDate}.", projectId, date); + return StatusCode(StatusCodes.Status500InternalServerError, "The selected story date could not be loaded."); + } + } + [HttpPost] [ValidateAntiForgeryToken] public IActionResult ApplyFilters(int projectId, int? bookId, string? month) @@ -66,4 +94,7 @@ public sealed class StoryCalendarController(IStoryCalendarService calendar) : Co ? parsed : null; } + + private static DateOnly FirstOfMonth(DateOnly date) + => new(date.Year, date.Month, 1); } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 394db59..2b6f36c 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -508,15 +508,31 @@ public sealed class StoryCalendarService(ITimelineRepository timelineRepository) } var options = new List(); - for (var month = FirstOfMonth(earliest.Value); month <= FirstOfMonth(latest.Value); month = month.AddMonths(1)) + var firstMonth = FirstOfMonth(earliest.Value); + var lastMonth = FirstOfMonth(latest.Value); + var normalizedSelectedMonth = selectedMonth.HasValue ? FirstOfMonth(selectedMonth.Value) : (DateOnly?)null; + + if (normalizedSelectedMonth.HasValue && normalizedSelectedMonth.Value < firstMonth) { - var value = month.ToString("yyyy-MM", CultureInfo.InvariantCulture); - options.Add(new SelectListItem(month.ToString("MMMM yyyy", UkCulture), value, selectedMonth == month)); + options.Add(BuildMonthOption(normalizedSelectedMonth.Value, selected: true)); + } + + for (var month = firstMonth; month <= lastMonth; month = month.AddMonths(1)) + { + options.Add(BuildMonthOption(month, normalizedSelectedMonth == month)); + } + + if (normalizedSelectedMonth.HasValue && normalizedSelectedMonth.Value > lastMonth) + { + options.Add(BuildMonthOption(normalizedSelectedMonth.Value, selected: true)); } return options; } + private static SelectListItem BuildMonthOption(DateOnly month, bool selected) + => new(month.ToString("MMMM yyyy", UkCulture), month.ToString("yyyy-MM", CultureInfo.InvariantCulture), selected); + private static DateOnly? AdjacentStoryMonth(IReadOnlyList datedScenes, DateOnly selectedMonth, bool previous) { var months = datedScenes diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 92c5abb..4739bdf 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -1273,6 +1273,12 @@ public sealed class StoryCalendarViewModel public IReadOnlyList DatedScenes { get; set; } = []; public IReadOnlyList UnplacedScenes { get; set; } = []; public StoryCalendarDayViewModel? SelectedDay { get; set; } + public StoryCalendarDayDetailsViewModel DayDetails => new() + { + ProjectID = Project.ProjectID, + SelectedBookID = SelectedBookID, + SelectedDay = SelectedDay + }; public int SceneCount => DatedScenes.Count; public int ActiveStoryDayCount => DatedScenes.Select(scene => scene.StoryDate).Distinct().Count(); public bool HasDatedScenes => DatedScenes.Count > 0; @@ -1293,6 +1299,14 @@ public sealed class StoryCalendarViewModel } } +public sealed class StoryCalendarDayDetailsViewModel +{ + public int ProjectID { get; set; } + public int? SelectedBookID { get; set; } + public StoryCalendarDayViewModel? SelectedDay { get; set; } + public bool ShowBookLabels => !SelectedBookID.HasValue; +} + public sealed class StoryCalendarWeekViewModel { public IReadOnlyList Days { get; set; } = []; diff --git a/PlotLine/Views/StoryCalendar/Index.cshtml b/PlotLine/Views/StoryCalendar/Index.cshtml index cce68d2..693754f 100644 --- a/PlotLine/Views/StoryCalendar/Index.cshtml +++ b/PlotLine/Views/StoryCalendar/Index.cshtml @@ -135,6 +135,10 @@ else asp-route-bookId="@Model.SelectedBookID" asp-route-month="@selectedMonthValue" asp-route-date="@day.QueryValue" + data-story-calendar-day + data-story-calendar-date="@day.QueryValue" + data-story-calendar-details-url="@Url.Action("DayDetails", "StoryCalendar", new { projectId = Model.Project.ProjectID, bookId = Model.SelectedBookID, date = day.QueryValue })" + aria-current="@(day.IsSelected ? "date" : null)" aria-label="@day.DayLabel"> @day.DayNumber @@ -161,61 +165,8 @@ else -