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 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>>([]);
|
||||
}
|
||||
|
||||
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<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>();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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>
|
||||
|
||||
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 {
|
||||
height: 4px;
|
||||
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