Improve story calendar day selection

This commit is contained in:
Nick Beckley 2026-09-05 22:51:04 +00:00
parent ce0ec5655c
commit 91331b0dd0
10 changed files with 298 additions and 61 deletions

View File

@ -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<PartialViewResult>(result, "Valid day details request should return a partial view.");
var model = AssertIs<StoryCalendarDayDetailsViewModel>(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<PartialViewResult>(allBooksController.DayDetails(1, null, "1983-01-02").GetAwaiter().GetResult(), "All-books day details should return a partial.");
var bookOne = AssertIs<PartialViewResult>(bookOneController.DayDetails(1, 10, "1983-01-02").GetAwaiter().GetResult(), "Book-filtered day details should return a partial.");
var allBooksModel = AssertIs<StoryCalendarDayDetailsViewModel>(allBooks.Model!, "All-books partial should use day details model.");
var bookOneModel = AssertIs<StoryCalendarDayDetailsViewModel>(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<PartialViewResult>(controller.DayDetails(1, null, "1982-12-29").GetAwaiter().GetResult(), "Empty day details should still return a partial.");
var model = AssertIs<StoryCalendarDayDetailsViewModel>(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<StoryCalendarController>.Instance);
static Scene CalendarScene(
int sceneId,
int bookId,
@ -7786,6 +7869,16 @@ static void Assert(bool condition, string message)
}
}
static T AssertIs<T>(object value, string message)
{
if (value is T typed)
{
return typed;
}
throw new InvalidOperationException(message);
}
static void AssertThrows(Action action, string message)
{
try

View File

@ -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<StoryCalendarController> logger) : Controller
{
public async Task<IActionResult> 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<IActionResult> 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);
}

View File

@ -508,15 +508,31 @@ public sealed class StoryCalendarService(ITimelineRepository timelineRepository)
}
var options = new List<SelectListItem>();
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<StoryCalendarSceneViewModel> datedScenes, DateOnly selectedMonth, bool previous)
{
var months = datedScenes

View File

@ -1273,6 +1273,12 @@ public sealed class StoryCalendarViewModel
public IReadOnlyList<StoryCalendarSceneViewModel> DatedScenes { get; set; } = [];
public IReadOnlyList<StoryCalendarSceneViewModel> 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<StoryCalendarDayViewModel> Days { get; set; } = [];

View File

@ -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">
<span class="story-calendar-day-number">@day.DayNumber</span>
<span class="story-calendar-day-scenes">
@ -161,61 +165,8 @@ else
</div>
</div>
<aside class="story-calendar-detail" aria-label="Selected day">
@if (Model.SelectedDay is null)
{
<h2>Select a story day</h2>
<p class="muted">Choose a date in the grid to inspect its scenes in narrative order.</p>
}
else
{
<h2>@Model.SelectedDay.DayLabel</h2>
@if (!Model.SelectedDay.Scenes.Any())
{
<p class="muted">No scenes are placed on this story date.</p>
}
else
{
<div class="story-calendar-detail-list">
@foreach (var scene in Model.SelectedDay.Scenes)
{
<article class="story-calendar-detail-scene">
<div class="story-calendar-detail-meta">
@if (showBookLabels)
{
<span>@scene.BookTitle</span>
}
<span>Chapter @scene.ChapterNumber:g</span>
<span>Scene @scene.SceneNumber:g</span>
</div>
<h3>@scene.Title</h3>
<dl>
@if (!string.IsNullOrWhiteSpace(scene.Pov))
{
<dt>POV</dt>
<dd>@scene.Pov</dd>
}
<dt>Time</dt>
<dd>@scene.TimeLabel</dd>
</dl>
@if (!string.IsNullOrWhiteSpace(scene.Summary))
{
<p>@scene.Summary</p>
}
<div class="button-row">
<a class="btn btn-outline-primary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open scene</a>
<a class="btn btn-outline-secondary btn-sm"
asp-controller="Timeline"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@scene.BookID"
asp-route-selectedSceneId="@scene.SceneID">Open in Timeline</a>
</div>
</article>
}
</div>
}
}
<aside class="story-calendar-detail" aria-label="Selected day" aria-live="polite" data-story-calendar-detail>
<partial name="_StoryCalendarDayDetails" model="Model.DayDetails" />
</aside>
</div>
</section>

View File

@ -0,0 +1,56 @@
@model StoryCalendarDayDetailsViewModel
@if (Model.SelectedDay is null)
{
<h2>Select a story day</h2>
<p class="muted">Choose a date in the grid to inspect its scenes in narrative order.</p>
}
else
{
<h2>@Model.SelectedDay.DayLabel</h2>
@if (!Model.SelectedDay.Scenes.Any())
{
<p class="muted">No scenes are placed on this story date.</p>
}
else
{
<div class="story-calendar-detail-list">
@foreach (var scene in Model.SelectedDay.Scenes)
{
<article class="story-calendar-detail-scene">
<div class="story-calendar-detail-meta">
@if (Model.ShowBookLabels)
{
<span>@scene.BookTitle</span>
}
<span>Chapter @scene.ChapterNumber:g</span>
<span>Scene @scene.SceneNumber:g</span>
</div>
<h3>@scene.Title</h3>
<dl>
@if (!string.IsNullOrWhiteSpace(scene.Pov))
{
<dt>POV</dt>
<dd>@scene.Pov</dd>
}
<dt>Time</dt>
<dd>@scene.TimeLabel</dd>
</dl>
@if (!string.IsNullOrWhiteSpace(scene.Summary))
{
<p>@scene.Summary</p>
}
<div class="button-row">
<a class="btn btn-outline-primary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open scene</a>
<a class="btn btn-outline-secondary btn-sm"
asp-controller="Timeline"
asp-action="Index"
asp-route-projectId="@Model.ProjectID"
asp-route-bookId="@scene.BookID"
asp-route-selectedSceneId="@scene.SceneID">Open in Timeline</a>
</div>
</article>
}
</div>
}
}

View File

@ -1537,6 +1537,23 @@ body.timeline-inspector-resizing {
font-size: 1.1rem;
}
.story-calendar-detail-notice {
margin: 0;
border: 1px solid var(--plotline-line);
border-radius: 6px;
padding: 0.45rem 0.6rem;
background: var(--plotline-soft);
color: var(--plotline-muted);
font-size: 0.86rem;
font-weight: 700;
}
.story-calendar-detail-notice.is-error {
border-color: color-mix(in srgb, var(--bs-danger) 34%, var(--plotline-line));
background: color-mix(in srgb, var(--bs-danger) 8%, var(--plotline-panel));
color: var(--bs-danger);
}
.story-calendar-detail-list {
display: grid;
gap: 0.75rem;

File diff suppressed because one or more lines are too long

View File

@ -2450,4 +2450,63 @@
showForm("location-relationship", "Create location relationship", `${payload.label} -> ${targetLabel}`, "Location drop");
});
});
const storyCalendarDetail = document.querySelector("[data-story-calendar-detail]");
if (storyCalendarDetail) {
const clearCalendarNotice = () => {
storyCalendarDetail.querySelectorAll("[data-story-calendar-notice]").forEach((notice) => notice.remove());
};
const showCalendarNotice = (message, state) => {
clearCalendarNotice();
const notice = document.createElement("p");
notice.className = `story-calendar-detail-notice ${state === "error" ? "is-error" : ""}`.trim();
notice.dataset.storyCalendarNotice = "true";
notice.textContent = message;
storyCalendarDetail.prepend(notice);
};
document.addEventListener("click", async (event) => {
const link = event.target.closest("[data-story-calendar-day]");
if (!link || !link.isConnected || !window.fetch) {
return;
}
const detailsUrl = link.dataset.storyCalendarDetailsUrl;
const selectedDate = link.dataset.storyCalendarDate;
if (!detailsUrl || !selectedDate) {
return;
}
event.preventDefault();
showCalendarNotice("Loading...", "loading");
try {
const response = await fetch(detailsUrl, {
headers: { "X-Requested-With": "XMLHttpRequest" },
credentials: "same-origin"
});
if (!response.ok) {
throw new Error("Day details load failed");
}
storyCalendarDetail.innerHTML = await response.text();
document.querySelectorAll("[data-story-calendar-day].is-selected").forEach((day) => {
day.classList.remove("is-selected");
day.removeAttribute("aria-current");
});
link.classList.add("is-selected");
link.setAttribute("aria-current", "date");
if (window.history?.pushState) {
const nextUrl = new URL(link.href, window.location.href);
nextUrl.searchParams.set("date", selectedDate);
window.history.pushState({ storyCalendarDate: selectedDate }, "", nextUrl.toString());
}
} catch {
showCalendarNotice("The selected story date could not be loaded. Use the date link to retry.", "error");
}
});
}
})();

File diff suppressed because one or more lines are too long