Add story calendar view

This commit is contained in:
Nick Beckley 2026-09-05 22:07:32 +00:00
parent 10da445713
commit ce0ec5655c
9 changed files with 1105 additions and 1 deletions

View File

@ -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<Scene>
{
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<TimelineData> GetByProjectAsync(int projectId, int? bookId, bool includeMetrics, IEnumerable<int> 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<IReadOnlyList<TimelineSceneOverviewRow>> GetSceneOverviewsAsync(int projectId, int? bookId)
=> Task.FromResult<IReadOnlyList<TimelineSceneOverviewRow>>([]);
}

View File

@ -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<IActionResult> 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;
}
}

View File

@ -175,6 +175,7 @@ public class Program
builder.Services.AddScoped<IStoryStateService, StoryStateService>();
builder.Services.AddScoped<IStoryContextService, StoryContextService>();
builder.Services.AddScoped<ITimelineService, TimelineService>();
builder.Services.AddScoped<IStoryCalendarService, StoryCalendarService>();
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();
builder.Services.AddScoped<IPlotService, PlotService>();

View File

@ -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<StoryCalendarViewModel?> 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<StoryCalendarViewModel?> 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<StoryCalendarWeekViewModel> BuildCalendarWeeks(
DateOnly selectedMonth,
IReadOnlyList<StoryCalendarSceneViewModel> 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<StoryCalendarSceneViewModel>)group
.OrderBy(scene => scene.TimeOfDayRank)
.ThenBy(scene => scene.StartDateTime?.TimeOfDay ?? TimeSpan.Zero)
.ThenBy(scene => scene.NarrativeOrder)
.ToList());
var weeks = new List<StoryCalendarWeekViewModel>();
for (var offset = 0; offset < totalDays; offset += 7)
{
var days = new List<StoryCalendarDayViewModel>();
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<StoryCalendarSceneViewModel> BuildSceneViewModels(
IReadOnlyList<Scene> scenes,
IReadOnlyDictionary<int, Chapter> chaptersById,
IReadOnlyDictionary<int, Book> 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<SelectListItem> BuildBookOptions(IReadOnlyList<Book> books, int? selectedBookId)
{
var items = new List<SelectListItem>
{
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<SelectListItem> BuildMonthOptions(DateOnly? earliest, DateOnly? latest, DateOnly? selectedMonth)
{
if (!earliest.HasValue || !latest.HasValue)
{
return [];
}
var options = new List<SelectListItem>();
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<StoryCalendarSceneViewModel> 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<DateOnly?>().LastOrDefault()
: months.Where(date => date > selectedMonth).Cast<DateOnly?>().FirstOrDefault();
return adjacent;
}
private static DateOnly FirstOfMonth(DateOnly date)
=> new(date.Year, date.Month, 1);
}
public interface ISceneMetricTypeService
{
Task<ProjectSceneMetricsViewModel?> GetAsync(int projectId);

View File

@ -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<SelectListItem> BookOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> MonthOptions { get; set; } = [];
public IReadOnlyList<StoryCalendarWeekViewModel> Weeks { get; set; } = [];
public IReadOnlyList<StoryCalendarSceneViewModel> DatedScenes { get; set; } = [];
public IReadOnlyList<StoryCalendarSceneViewModel> 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<StoryCalendarDayViewModel> 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<StoryCalendarSceneViewModel> 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; }

View File

@ -12,6 +12,7 @@
<nav class="pw-project-nav__links" aria-label="Project sections">
<a class="pw-project-nav__link @(ActiveClass("Overview"))" asp-controller="Projects" asp-action="Details" asp-route-id="@projectId">Overview</a>
<a class="pw-project-nav__link @(ActiveClass("Timeline"))" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@projectId">Timeline</a>
<a class="pw-project-nav__link @(ActiveClass("Calendar"))" asp-controller="StoryCalendar" asp-action="Index" asp-route-projectId="@projectId">Calendar</a>
<a class="pw-project-nav__link @(ActiveClass("Characters"))" asp-controller="Characters" asp-action="Index" asp-route-projectId="@projectId">Characters</a>
<a class="pw-project-nav__link @(ActiveClass("Names"))" asp-controller="Names" asp-action="Index" asp-route-projectId="@projectId">Names</a>
<a class="pw-project-nav__link @(ActiveClass("Relationships"))" asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@projectId">Relationships</a>

View File

@ -0,0 +1,258 @@
@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" };
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<span>Calendar</span>
</nav>
<partial name="_ProjectSectionNav" model="Model.Project" />
<div class="page-heading story-calendar-heading">
<div>
<p class="eyebrow">Story time</p>
<h1>Calendar</h1>
<p class="lead-text">@Model.StoryRangeLabel</p>
</div>
<div class="story-calendar-stats" aria-label="Calendar summary">
<span><strong>@Model.SceneCount</strong> dated scenes</span>
<span><strong>@Model.ActiveStoryDayCount</strong> story days</span>
<span><strong>@Model.UnplacedScenes.Count</strong> unplaced</span>
</div>
</div>
<section class="card mb-3 story-calendar-filter">
<div class="card-body">
<form asp-action="ApplyFilters" method="post" class="row g-2 align-items-end">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<div class="col-md-5 col-lg-4">
<label class="form-label" for="bookId">Book</label>
<select class="form-select" id="bookId" name="bookId" asp-items="Model.BookOptions"></select>
</div>
<div class="col-md-4 col-lg-3">
<label class="form-label" for="month">Month</label>
<select class="form-select" id="month" name="month" disabled="@(!Model.MonthOptions.Any())">
@if (!Model.MonthOptions.Any())
{
<option value="">No dated scenes</option>
}
else
{
@foreach (var option in Model.MonthOptions)
{
<option value="@option.Value" selected="@option.Selected">@option.Text</option>
}
}
</select>
</div>
<div class="col-md-3 col-lg-2">
<button class="btn btn-primary w-100" type="submit">Apply</button>
</div>
</form>
</div>
</section>
@if (!Model.HasDatedScenes)
{
<section class="card story-calendar-empty">
<div class="card-body">
<h2>No dated scenes yet</h2>
<p class="muted mb-0">Scenes will appear here once canonical scene chronology has a resolved start date.</p>
</div>
</section>
}
else
{
<section class="story-calendar-shell">
<div class="story-calendar-toolbar">
<div class="button-row">
<a class="btn btn-outline-secondary btn-sm"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@Model.PreviousMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture)">Previous month</a>
<a class="btn btn-outline-secondary btn-sm"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@Model.NextMonth?.ToString("yyyy-MM", CultureInfo.InvariantCulture)">Next month</a>
@if (Model.FirstStoryMonth.HasValue)
{
<a class="btn btn-outline-secondary btn-sm"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@Model.FirstStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)">First dated scene</a>
}
</div>
<div class="story-calendar-story-months">
@if (Model.PreviousStoryMonth.HasValue)
{
<a asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@Model.PreviousStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)">Previous story month</a>
}
@if (Model.NextStoryMonth.HasValue)
{
<a asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@Model.NextStoryMonth.Value.ToString("yyyy-MM", CultureInfo.InvariantCulture)">Next story month</a>
}
</div>
</div>
<div class="story-calendar-layout">
<div class="story-calendar-grid-wrap">
<div class="story-calendar-month" aria-label="@Model.SelectedMonthLabel">
<div class="story-calendar-month-title">@Model.SelectedMonthLabel</div>
<div class="story-calendar-weekdays">
@foreach (var weekday in weekdays)
{
<div>@weekday</div>
}
</div>
<div class="story-calendar-grid">
@foreach (var week in Model.Weeks)
{
@foreach (var day in week.Days)
{
var visibleScenes = day.Scenes.Take(3).ToList();
var hiddenCount = Math.Max(0, day.Scenes.Count - visibleScenes.Count);
<a class="story-calendar-day @(day.IsInSelectedMonth ? "" : "is-overflow") @(day.IsSelected ? "is-selected" : "")"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-month="@selectedMonthValue"
asp-route-date="@day.QueryValue"
aria-label="@day.DayLabel">
<span class="story-calendar-day-number">@day.DayNumber</span>
<span class="story-calendar-day-scenes">
@foreach (var scene in visibleScenes)
{
<span class="story-calendar-scene-chip">
@if (showBookLabels)
{
<span class="story-calendar-book">@scene.BookLabel</span>
}
<span>@scene.SceneLabel</span>
<strong>@scene.Title</strong>
</span>
}
@if (hiddenCount > 0)
{
<span class="story-calendar-more">+ @hiddenCount more</span>
}
</span>
</a>
}
}
</div>
</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>
</div>
</section>
}
<section class="card story-calendar-unplaced">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Unplaced scenes</strong>
<button class="btn btn-outline-secondary btn-sm" type="button" data-bs-toggle="collapse" data-bs-target="#story-calendar-unplaced" aria-expanded="false" aria-controls="story-calendar-unplaced">
@Model.UnplacedScenes.Count scenes
</button>
</div>
<div class="collapse" id="story-calendar-unplaced">
<div class="card-body">
@if (!Model.UnplacedScenes.Any())
{
<p class="muted mb-0">All scenes in this scope have resolved story dates.</p>
}
else
{
<div class="story-calendar-unplaced-list">
@foreach (var scene in Model.UnplacedScenes)
{
<article>
<div>
<strong>@scene.Title</strong>
<span>@scene.BookTitle · Chapter @scene.ChapterNumber:g · Scene @scene.SceneNumber:g</span>
@if (!string.IsNullOrWhiteSpace(scene.RelativeTimeText))
{
<small>@scene.RelativeTimeText</small>
}
</div>
<a class="btn btn-outline-primary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Open scene</a>
</article>
}
</div>
}
</div>
</div>
</section>

View File

@ -1328,6 +1328,311 @@ body.timeline-inspector-resizing {
);
}
.story-calendar-heading {
gap: 1rem;
}
.story-calendar-stats {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: flex-end;
}
.story-calendar-stats span {
display: inline-flex;
gap: 0.35rem;
align-items: baseline;
border: 1px solid var(--plotline-line);
border-radius: 999px;
padding: 0.35rem 0.7rem;
background: var(--plotline-panel);
color: var(--plotline-muted);
font-size: 0.88rem;
}
.story-calendar-stats strong {
color: var(--plotline-ink);
}
.story-calendar-filter .form-label {
font-size: 0.82rem;
font-weight: 800;
color: var(--plotline-muted);
}
.story-calendar-shell {
display: grid;
gap: 0.85rem;
}
.story-calendar-toolbar,
.story-calendar-layout,
.story-calendar-month,
.story-calendar-detail {
border: 1px solid var(--plotline-line);
border-radius: 8px;
background: var(--plotline-panel);
}
.story-calendar-toolbar {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
}
.story-calendar-story-months {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.9rem;
font-weight: 700;
}
.story-calendar-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 0;
overflow: hidden;
}
.story-calendar-grid-wrap {
min-width: 0;
overflow-x: auto;
}
.story-calendar-month {
min-width: 860px;
border: 0;
border-radius: 0;
}
.story-calendar-month-title {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--plotline-line);
color: var(--plotline-ink);
font-size: 1.15rem;
font-weight: 850;
}
.story-calendar-weekdays,
.story-calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
}
.story-calendar-weekdays {
border-bottom: 1px solid var(--plotline-line);
background: var(--plotline-soft);
}
.story-calendar-weekdays div {
padding: 0.55rem 0.65rem;
border-right: 1px solid var(--plotline-line);
color: var(--plotline-muted);
font-size: 0.76rem;
font-weight: 850;
text-transform: uppercase;
}
.story-calendar-weekdays div:last-child {
border-right: 0;
}
.story-calendar-day {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.35rem;
min-height: 132px;
border-right: 1px solid var(--plotline-line);
border-bottom: 1px solid var(--plotline-line);
padding: 0.55rem;
background: var(--plotline-panel);
color: var(--plotline-ink);
text-decoration: none;
}
.story-calendar-day:nth-child(7n) {
border-right: 0;
}
.story-calendar-day:hover,
.story-calendar-day:focus {
background: color-mix(in srgb, var(--plotline-soft) 72%, var(--plotline-panel));
color: var(--plotline-ink);
}
.story-calendar-day.is-overflow {
background: color-mix(in srgb, var(--plotline-paper) 82%, var(--plotline-line));
color: var(--plotline-muted);
}
.story-calendar-day.is-selected {
outline: 3px solid color-mix(in srgb, var(--plotline-accent) 34%, transparent);
outline-offset: -3px;
background: color-mix(in srgb, var(--plotline-soft) 86%, var(--plotline-panel));
}
.story-calendar-day-number {
font-size: 0.95rem;
font-weight: 850;
}
.story-calendar-day-scenes {
display: grid;
align-content: start;
gap: 0.3rem;
min-width: 0;
}
.story-calendar-scene-chip {
display: grid;
gap: 0.08rem;
border: 1px solid color-mix(in srgb, var(--plotline-accent) 28%, var(--plotline-line));
border-radius: 6px;
padding: 0.3rem 0.4rem;
background: color-mix(in srgb, var(--plotline-panel) 88%, var(--plotline-soft));
color: var(--plotline-muted);
font-size: 0.72rem;
line-height: 1.18;
}
.story-calendar-scene-chip strong {
overflow: hidden;
color: var(--plotline-ink);
font-size: 0.78rem;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.story-calendar-book,
.story-calendar-more {
color: var(--plotline-accent-dark);
font-weight: 850;
}
.story-calendar-more {
font-size: 0.76rem;
}
.story-calendar-detail {
display: grid;
align-content: start;
gap: 0.8rem;
max-height: 780px;
overflow: auto;
border-top: 0;
border-right: 0;
border-bottom: 0;
border-radius: 0;
padding: 1rem;
}
.story-calendar-detail h2 {
margin: 0;
color: var(--plotline-ink);
font-size: 1.1rem;
}
.story-calendar-detail-list {
display: grid;
gap: 0.75rem;
}
.story-calendar-detail-scene {
display: grid;
gap: 0.55rem;
border: 1px solid var(--plotline-line);
border-radius: 8px;
padding: 0.75rem;
background: color-mix(in srgb, var(--plotline-panel) 94%, var(--plotline-paper));
}
.story-calendar-detail-scene h3 {
margin: 0;
color: var(--plotline-ink);
font-size: 1rem;
}
.story-calendar-detail-scene p,
.story-calendar-detail-scene dl {
margin: 0;
}
.story-calendar-detail-scene dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.15rem 0.45rem;
font-size: 0.86rem;
}
.story-calendar-detail-scene dt,
.story-calendar-detail-meta {
color: var(--plotline-muted);
font-weight: 800;
}
.story-calendar-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
font-size: 0.76rem;
}
.story-calendar-unplaced {
margin-top: 1rem;
}
.story-calendar-unplaced-list {
display: grid;
gap: 0.55rem;
}
.story-calendar-unplaced-list article {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
border: 1px solid var(--plotline-line);
border-radius: 8px;
padding: 0.65rem 0.75rem;
background: var(--plotline-panel);
}
.story-calendar-unplaced-list article div {
display: grid;
gap: 0.15rem;
}
.story-calendar-unplaced-list span,
.story-calendar-unplaced-list small {
color: var(--plotline-muted);
}
@media (max-width: 960px) {
.story-calendar-toolbar,
.story-calendar-layout {
display: grid;
}
.story-calendar-layout {
grid-template-columns: 1fr;
}
.story-calendar-detail {
max-height: none;
border-top: 1px solid var(--plotline-line);
border-left: 0;
}
.story-calendar-stats {
justify-content: flex-start;
}
}
.metric-point-placeholder::before {
height: 4px;
background: rgba(102, 113, 123, 0.2);

File diff suppressed because one or more lines are too long