Add story calendar view
This commit is contained in:
parent
10da445713
commit
ce0ec5655c
@ -128,6 +128,11 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Story Intelligence temporal importer preserves manual chronology", StoryIntelligenceTemporalImporterPreservesManualChronology),
|
("Story Intelligence temporal importer preserves manual chronology", StoryIntelligenceTemporalImporterPreservesManualChronology),
|
||||||
("Story Intelligence temporal materialisation saves canonical chronology", StoryIntelligenceTemporalMaterialisationSavesCanonicalChronology),
|
("Story Intelligence temporal materialisation saves canonical chronology", StoryIntelligenceTemporalMaterialisationSavesCanonicalChronology),
|
||||||
("Story Intelligence temporal malformed values are discarded", StoryIntelligenceTemporalMalformedValuesAreDiscarded),
|
("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 metric values validate and import", StoryIntelligenceStructuredMetricValuesValidateAndImport),
|
||||||
("Story Intelligence structured schema covers complete response model", StoryIntelligenceStructuredSchemaCoversCompleteResponseModel),
|
("Story Intelligence structured schema covers complete response model", StoryIntelligenceStructuredSchemaCoversCompleteResponseModel),
|
||||||
("Story Intelligence structured schema remains fixture agnostic", StoryIntelligenceStructuredSchemaRemainsFixtureAgnostic),
|
("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.");
|
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()
|
static void StoryIntelligenceStructuredMetricValuesValidateAndImport()
|
||||||
{
|
{
|
||||||
var validator = new StorySceneValidator();
|
var validator = new StorySceneValidator();
|
||||||
@ -7505,6 +7568,81 @@ static SceneIntelligenceScene SceneWithoutTemporalExtraction()
|
|||||||
TimelineClues = []
|
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()
|
static JsonElement SceneSchemaRoot()
|
||||||
=> JsonSerializer.SerializeToElement(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);
|
=> JsonSerializer.SerializeToElement(StoryIntelligenceSceneStructuredOutputSchema.Contract.Schema);
|
||||||
|
|
||||||
@ -7927,3 +8065,31 @@ internal sealed class FakeManuscriptTextStatisticsRepository(string sourceText)
|
|||||||
SourceText = 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>>([]);
|
||||||
|
}
|
||||||
|
|||||||
69
PlotLine/Controllers/StoryCalendarController.cs
Normal file
69
PlotLine/Controllers/StoryCalendarController.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -175,6 +175,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<IStoryStateService, StoryStateService>();
|
builder.Services.AddScoped<IStoryStateService, StoryStateService>();
|
||||||
builder.Services.AddScoped<IStoryContextService, StoryContextService>();
|
builder.Services.AddScoped<IStoryContextService, StoryContextService>();
|
||||||
builder.Services.AddScoped<ITimelineService, TimelineService>();
|
builder.Services.AddScoped<ITimelineService, TimelineService>();
|
||||||
|
builder.Services.AddScoped<IStoryCalendarService, StoryCalendarService>();
|
||||||
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
|
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
|
||||||
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();
|
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();
|
||||||
builder.Services.AddScoped<IPlotService, PlotService>();
|
builder.Services.AddScoped<IPlotService, PlotService>();
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@ -312,6 +313,228 @@ public interface ITimelineService
|
|||||||
Task AddLocationRelationshipByDropAsync(TimelineLocationRelationshipDropViewModel model);
|
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
|
public interface ISceneMetricTypeService
|
||||||
{
|
{
|
||||||
Task<ProjectSceneMetricsViewModel?> GetAsync(int projectId);
|
Task<ProjectSceneMetricsViewModel?> GetAsync(int projectId);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Globalization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
@ -1253,6 +1254,86 @@ public sealed class TimelineViewModel
|
|||||||
public ContinuityValidationSummary? LastValidationSummary { get; set; }
|
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 sealed class TimelineSettingsViewModel
|
||||||
{
|
{
|
||||||
public int ProjectID { get; set; }
|
public int ProjectID { get; set; }
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
<nav class="pw-project-nav__links" aria-label="Project sections">
|
<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("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("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("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("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>
|
<a class="pw-project-nav__link @(ActiveClass("Relationships"))" asp-controller="RelationshipMap" asp-action="Index" asp-route-projectId="@projectId">Relationships</a>
|
||||||
|
|||||||
258
PlotLine/Views/StoryCalendar/Index.cshtml
Normal file
258
PlotLine/Views/StoryCalendar/Index.cshtml
Normal 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>
|
||||||
@ -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 {
|
.metric-point-placeholder::before {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: rgba(102, 113, 123, 0.2);
|
background: rgba(102, 113, 123, 0.2);
|
||||||
|
|||||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user