diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 1d10751..1022a3e 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -3,6 +3,7 @@ using System.Net; using System.Text; using System.IO.Compression; using System.Globalization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -139,6 +140,7 @@ var tests = new (string Name, Action Test)[] ("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 index returns surface partial for AJAX", StoryCalendarIndexReturnsSurfacePartialForAjax), ("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), @@ -7493,17 +7495,36 @@ static void StoryCalendarDayDetailsEndpointRejectsInvalidProject() Assert(typeof(StoryCalendarController).GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: false).Any(), "Story Calendar endpoints should remain authorization protected."); } +static void StoryCalendarIndexReturnsSurfacePartialForAjax() +{ + var controller = CreateStoryCalendarController(BuildCalendarTimelineData()); + controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; + controller.Request.Headers["X-Requested-With"] = "XMLHttpRequest"; + + var partial = AssertIs(controller.Index(1, null, "1982-12", "1982-12-28").GetAwaiter().GetResult(), "AJAX calendar navigation should return a partial view."); + var model = AssertIs(partial.Model!, "Calendar surface partial should receive the full calendar model."); + + Assert(partial.ViewName == "_StoryCalendarSurface", "AJAX calendar navigation should return the replaceable Calendar surface."); + Assert(model.SelectedMonth == new DateOnly(1982, 12, 1), "AJAX calendar navigation should preserve selected month."); + Assert(model.SelectedDate == new DateOnly(1982, 12, 28), "AJAX calendar navigation should preserve selected date."); +} + static void StoryCalendarDaySelectionUsesProgressiveAjaxHooks() { var view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/StoryCalendar/Index.cshtml")); + var surface = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine/Views/StoryCalendar/_StoryCalendarSurface.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(view.Contains("data-story-calendar-root", StringComparison.Ordinal), "Calendar page should expose a replaceable AJAX root."); + Assert(surface.Contains("data-story-calendar-day", StringComparison.Ordinal), "Calendar date links should expose a progressive enhancement hook."); + Assert(surface.Contains("data-story-calendar-nav", StringComparison.Ordinal), "Calendar month navigation links should expose a progressive enhancement hook."); + Assert(surface.Contains("data-story-calendar-filter", StringComparison.Ordinal), "Calendar filter form should expose a progressive enhancement hook."); + Assert(surface.Contains("asp-action=\"Index\"", StringComparison.Ordinal), "Calendar date links should keep their full-page fallback href."); + Assert(script.Contains("loadCalendarSurface(link.href", StringComparison.Ordinal), "Calendar links should load the full calendar surface asynchronously."); + Assert(script.Contains("buildFilterUrl(form)", StringComparison.Ordinal), "Calendar dropdown/form changes should load the calendar surface asynchronously."); + Assert(script.Contains("root.innerHTML = await response.text()", StringComparison.Ordinal), "AJAX calendar navigation should replace the calendar section."); 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."); + Assert(script.Contains("popstate", StringComparison.Ordinal), "Browser back/forward should reload the calendar surface without a full page reload."); } static void StoryIntelligenceStructuredMetricValuesValidateAndImport() diff --git a/PlotLine/Controllers/StoryCalendarController.cs b/PlotLine/Controllers/StoryCalendarController.cs index 3409333..18ae057 100644 --- a/PlotLine/Controllers/StoryCalendarController.cs +++ b/PlotLine/Controllers/StoryCalendarController.cs @@ -20,7 +20,14 @@ public sealed class StoryCalendarController(IStoryCalendarService calendar, ILog var selectedMonth = ParseMonth(month); var selectedDate = ParseDate(date); var model = await calendar.GetAsync(projectId, bookId, selectedMonth, selectedDate); - return model is null ? NotFound() : View(model); + if (model is null) + { + return NotFound(); + } + + return IsAjaxRequest() + ? PartialView("_StoryCalendarSurface", model) + : View(model); } [HttpGet] @@ -97,4 +104,7 @@ public sealed class StoryCalendarController(IStoryCalendarService calendar, ILog private static DateOnly FirstOfMonth(DateOnly date) => new(date.Year, date.Month, 1); + + private bool IsAjaxRequest() + => string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase); } diff --git a/PlotLine/Views/StoryCalendar/Index.cshtml b/PlotLine/Views/StoryCalendar/Index.cshtml index 693754f..d2821db 100644 --- a/PlotLine/Views/StoryCalendar/Index.cshtml +++ b/PlotLine/Views/StoryCalendar/Index.cshtml @@ -1,12 +1,7 @@ @model StoryCalendarViewModel -@using System.Globalization @{ ViewData["Title"] = $"Calendar - {Model.Project.ProjectName}"; ViewData["ProjectSection"] = "Calendar"; - var selectedMonthValue = Model.SelectedMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture); - var selectedDateValue = Model.SelectedDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); - var showBookLabels = !Model.SelectedBookID.HasValue; - var weekdays = new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }; }