From ac07a52e2797bee898392865b1cbf8ad694b8500 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 13 Jun 2026 12:43:19 +0100 Subject: [PATCH] Phase 9H: per-day writing session lengths. --- PlotLine/Controllers/WriterController.cs | 18 +- PlotLine/Data/Repositories.cs | 3 + PlotLine/Models/CoreModels.cs | 3 + PlotLine/Services/CoreServices.cs | 391 +++++++++++++++---- PlotLine/ViewModels/CoreViewModels.cs | 11 + PlotLine/Views/Writer/EditWritingPlan.cshtml | 46 ++- PlotLine/Views/Writer/SchedulePreview.cshtml | 2 + PlotLine/Views/Writer/ScheduleSetup.cshtml | 46 ++- PlotLine/Views/Writer/WritingPlans.cshtml | 4 +- 9 files changed, 403 insertions(+), 121 deletions(-) diff --git a/PlotLine/Controllers/WriterController.cs b/PlotLine/Controllers/WriterController.cs index df2dffb..34d90df 100644 --- a/PlotLine/Controllers/WriterController.cs +++ b/PlotLine/Controllers/WriterController.cs @@ -52,15 +52,10 @@ public sealed class WriterController( [ValidateAntiForgeryToken] public async Task EditWritingPlan(WritingPlanSettingsEditViewModel model) { - if (!Request.Form.ContainsKey(nameof(model.SelectedWritingDays))) - { - model.SelectedWritingDays = []; - } - - if (!model.SelectedWritingDays.Any()) + if (!model.WritingDayAvailability.Any(x => x.IsAvailable)) { ModelState.AddModelError(string.Empty, "Select at least one writing day."); - ModelState.AddModelError(nameof(model.SelectedWritingDays), "Select at least one writing day."); + ModelState.AddModelError(nameof(model.WritingDayAvailability), "Select at least one writing day."); } if (model.DeadlineDate.Date < model.StartDate.Date) @@ -114,15 +109,10 @@ public sealed class WriterController( [ValidateAntiForgeryToken] public async Task ScheduleSetup(WritingScheduleSetupViewModel model) { - if (!Request.Form.ContainsKey(nameof(model.SelectedWritingDays))) - { - model.SelectedWritingDays = []; - } - - if (!model.SelectedWritingDays.Any()) + if (!model.WritingDayAvailability.Any(x => x.IsAvailable)) { ModelState.AddModelError(string.Empty, "Select at least one writing day."); - ModelState.AddModelError(nameof(model.SelectedWritingDays), "Select at least one writing day."); + ModelState.AddModelError(nameof(model.WritingDayAvailability), "Select at least one writing day."); } if (model.DeadlineDate.Date < model.StartDate.Date) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 6bc3e9e..6b684fc 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -1030,6 +1030,7 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa wp.GoalType, wp.StartDate, wp.DeadlineDate, + wp.AvailableDaysJson, wp.SessionLengthMinutes, wp.IsActive, SUM(CASE WHEN wsi.ScheduleStatus = N'Planned' THEN 1 ELSE 0 END) AS PlannedTaskCount, @@ -1049,6 +1050,7 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa wp.GoalType, wp.StartDate, wp.DeadlineDate, + wp.AvailableDaysJson, wp.SessionLengthMinutes, wp.IsActive ORDER BY wp.IsActive DESC, wp.DeadlineDate, wp.PlanName, wp.WritingPlanID; @@ -1182,6 +1184,7 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa wsi.EstimatedMinutes, wsi.TargetWords, wsi.ScheduleStatus, + wsi.Notes, COALESCE(sw.IsBlocked, 0) AS IsBlocked FROM dbo.WritingScheduleItems wsi INNER JOIN dbo.Scenes s ON s.SceneID = wsi.SceneID diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index a07ce8f..c7ac09c 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -1644,7 +1644,9 @@ public sealed class WritingPlanManagementItem public string GoalType { get; set; } = string.Empty; public DateTime StartDate { get; set; } public DateTime DeadlineDate { get; set; } + public string AvailableDaysJson { get; set; } = string.Empty; public int SessionLengthMinutes { get; set; } + public string AvailabilitySummary { get; set; } = string.Empty; public bool IsActive { get; set; } public int PlannedTaskCount { get; set; } public DateTime? ProjectedFinishDate { get; set; } @@ -1666,6 +1668,7 @@ public sealed class WritingSchedulePreviewItem public int? EstimatedMinutes { get; set; } public int? TargetWords { get; set; } public string ScheduleStatus { get; set; } = string.Empty; + public string? Notes { get; set; } public bool IsBlocked { get; set; } } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 54e12a3..0c2c5eb 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -3300,6 +3300,25 @@ public sealed class WritingScheduleService( IBookRepository books, ICurrentUserService currentUser) : IWritingScheduleService { + private static readonly IReadOnlyList WritingDays = + [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ]; + + private static readonly IReadOnlyList AllowedSessionLengths = [30, 60, 90, 120, 150, 180, 240, 300, 360, 480, 600, 720]; + + private sealed record WritingDayAvailability(string Day, DayOfWeek DayOfWeek, int Minutes); + + private sealed record AvailableWritingDate(DateTime Date, int Minutes); + + private sealed record ScheduleTaskEstimate(WritingScheduleScene Scene, string TaskType, int EstimatedMinutes, int? TargetWords); + public async Task> GetWritingPlansByUserAsync(int userId) { var plans = await writingSchedule.GetWritingPlansByUserAsync(userId); @@ -3379,8 +3398,8 @@ public sealed class WritingScheduleService( var planViewModel = ToViewModel(plan); ValidatePlan(planViewModel); - var availableDays = ParseAvailableDays(plan.AvailableDaysJson); - if (availableDays.Count == 0) + var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes); + if (availability.Count == 0) { throw new InvalidOperationException("Available days must include at least one valid day."); } @@ -3392,15 +3411,8 @@ public sealed class WritingScheduleService( return []; } - var scheduledDates = GetAvailableDates(plan.StartDate, plan.DeadlineDate, availableDays); - if (scheduledDates.Count < scenes.Count) - { - throw new InvalidOperationException("Insufficient available writing sessions before the selected deadline."); - } - - var items = scenes - .Select((scene, index) => CreateGeneratedItem(plan.WritingPlanID, scene, scheduledDates[index])) - .ToList(); + var availableDates = GetAvailableDates(plan.StartDate, plan.DeadlineDate, availability); + var items = BuildGeneratedScheduleItems(plan.WritingPlanID, scenes, availableDates); await writingSchedule.ReplaceScheduleItemsAsync(plan.WritingPlanID, items); @@ -3456,6 +3468,11 @@ public sealed class WritingScheduleService( } var plans = await writingSchedule.GetWritingPlansForManagementAsync(currentUser.UserId.Value); + foreach (var plan in plans) + { + plan.AvailabilitySummary = FormatAvailabilitySummary(plan.AvailableDaysJson, plan.SessionLengthMinutes); + } + return new WritingPlansManagementViewModel { Plans = plans }; } @@ -3464,8 +3481,9 @@ public sealed class WritingScheduleService( var plan = await GetPlanForCurrentUserAsync(writingPlanId); var project = await projects.GetAsync(plan.ProjectID); var book = plan.BookID.HasValue ? await books.GetAsync(plan.BookID.Value) : null; - var selectedDays = ParseAvailableDays(plan.AvailableDaysJson) - .Select(x => x.ToString()) + var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes); + var selectedDays = availability + .Select(x => x.Day) .ToList(); var model = new WritingPlanSettingsEditViewModel @@ -3479,6 +3497,7 @@ public sealed class WritingScheduleService( DeadlineDate = plan.DeadlineDate.Date, SessionLengthMinutes = plan.SessionLengthMinutes, SelectedWritingDays = selectedDays, + WritingDayAvailability = BuildWritingDayAvailabilityInputs(availability, plan.SessionLengthMinutes).ToList(), IncludeBlockedScenes = plan.IncludeBlockedScenes, RebalanceMode = plan.RebalanceMode, IsActive = plan.IsActive @@ -3491,16 +3510,16 @@ public sealed class WritingScheduleService( { var plan = await GetPlanForCurrentUserAsync(model.WritingPlanID); ValidatePlanSettings(model); - var writingDays = NormaliseWritingDays(model.SelectedWritingDays); - if (writingDays.Count == 0) + var availability = NormaliseWritingAvailability(model.WritingDayAvailability, model.SessionLengthMinutes); + if (availability.Count == 0) { throw new InvalidOperationException("Select at least one writing day."); } plan.PlanName = model.PlanName.Trim(); plan.DeadlineDate = model.DeadlineDate.Date; - plan.AvailableDaysJson = JsonSerializer.Serialize(writingDays); - plan.SessionLengthMinutes = model.SessionLengthMinutes; + plan.AvailableDaysJson = JsonSerializer.Serialize(ToAvailabilityJson(availability)); + plan.SessionLengthMinutes = availability.First().Minutes; plan.IncludeBlockedScenes = model.IncludeBlockedScenes; plan.RebalanceMode = model.RebalanceMode; plan.IsActive = model.IsActive; @@ -3530,6 +3549,13 @@ public sealed class WritingScheduleService( private static void PopulatePlanSettingsOptions(WritingPlanSettingsEditViewModel model) { model.RebalanceModeOptions = ScheduleOptionList(["Ask", "Automatic", "Never"], model.RebalanceMode); + model.SessionLengthOptions = SessionLengthOptionList(); + if (model.WritingDayAvailability.Count == 0) + { + model.WritingDayAvailability = BuildWritingDayAvailabilityInputs( + model.SelectedWritingDays.Select(day => new WritingDayAvailability(day, Enum.Parse(day), model.SessionLengthMinutes)).ToList(), + model.SessionLengthMinutes).ToList(); + } } public async Task GetScheduleSetupAsync(int? projectId) @@ -3572,6 +3598,13 @@ public sealed class WritingScheduleService( .ToList(); model.GoalTypeOptions = ScheduleOptionList(["FirstDraft", "Revision", "BetaReady", "Custom"], model.GoalType); model.RebalanceModeOptions = ScheduleOptionList(["Ask", "Automatic", "Never"], model.RebalanceMode); + model.SessionLengthOptions = SessionLengthOptionList(); + if (model.WritingDayAvailability.Count == 0) + { + model.WritingDayAvailability = BuildWritingDayAvailabilityInputs( + model.SelectedWritingDays.Select(day => new WritingDayAvailability(day, Enum.Parse(day), model.SessionLengthMinutes)).ToList(), + model.SessionLengthMinutes).ToList(); + } } public async Task CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model) @@ -3582,8 +3615,8 @@ public sealed class WritingScheduleService( } ValidateSetup(model); - var writingDays = NormaliseWritingDays(model.SelectedWritingDays); - if (writingDays.Count == 0) + var availability = NormaliseWritingAvailability(model.WritingDayAvailability, model.SessionLengthMinutes); + if (availability.Count == 0) { throw new InvalidOperationException("Select at least one writing day."); } @@ -3612,8 +3645,8 @@ public sealed class WritingScheduleService( GoalType = model.GoalType, StartDate = model.StartDate.Date, DeadlineDate = model.DeadlineDate.Date, - AvailableDaysJson = JsonSerializer.Serialize(writingDays), - SessionLengthMinutes = model.SessionLengthMinutes, + AvailableDaysJson = JsonSerializer.Serialize(ToAvailabilityJson(availability)), + SessionLengthMinutes = availability.First().Minutes, IncludeBlockedScenes = model.IncludeBlockedScenes, RebalanceMode = model.RebalanceMode, IsActive = true @@ -3732,13 +3765,13 @@ public sealed class WritingScheduleService( return new WritingScheduleRebalancePreviewViewModel { WritingPlanID = plan.WritingPlanID }; } - var availableDays = ParseAvailableDays(plan.AvailableDaysJson); - if (availableDays.Count == 0) + var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes); + if (availability.Count == 0) { throw new InvalidOperationException("Available days must include at least one valid day."); } - var proposedDates = GetNextAvailableDates(DateTime.Today, availableDays, plannedItems.Count); + var proposedDates = GetNextAvailableDates(DateTime.Today, availability, plannedItems); var changes = plannedItems .Select((item, index) => new { Item = item, ProposedDate = proposedDates[index] }) .Where(x => x.Item.ScheduledDate.Date != x.ProposedDate.Date) @@ -3825,10 +3858,7 @@ public sealed class WritingScheduleService( throw new InvalidOperationException("Deadline date must be on or after the start date."); } - if (model.SessionLengthMinutes <= 0) - { - throw new InvalidOperationException("Session length minutes must be greater than zero."); - } + ValidateAvailability(model.WritingDayAvailability, model.SessionLengthMinutes); } private static void ValidatePlanSettings(WritingPlanSettingsEditViewModel model) @@ -3843,28 +3873,136 @@ public sealed class WritingScheduleService( throw new InvalidOperationException("Deadline date must be on or after the start date."); } - if (model.SessionLengthMinutes <= 0) - { - throw new InvalidOperationException("Session length minutes must be greater than zero."); - } - } - - private static IReadOnlyList NormaliseWritingDays(IEnumerable? selectedDays) - { - var selected = (selectedDays ?? []) - .Where(day => !string.IsNullOrWhiteSpace(day)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - return - [ - .. new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" } - .Where(selected.Contains) - ]; + ValidateAvailability(model.WritingDayAvailability, model.SessionLengthMinutes); } private static IReadOnlyList ScheduleOptionList(IEnumerable values, string selectedValue) => values.Select(value => new SelectListItem(value, value, string.Equals(value, selectedValue, StringComparison.OrdinalIgnoreCase))).ToList(); + private static IReadOnlyList SessionLengthOptionList() + => + [ + new("30 minutes", "30"), + new("1 hour", "60"), + new("1 1/2 hours", "90"), + new("2 hours", "120"), + new("2 1/2 hours", "150"), + new("3 hours", "180"), + new("4 hours", "240"), + new("5 hours", "300"), + new("6 hours", "360"), + new("8 hours", "480"), + new("10 hours", "600"), + new("12 hours", "720") + ]; + + private static void ValidateAvailability(IReadOnlyList availabilityInputs, int fallbackSessionLengthMinutes) + { + var availability = NormaliseWritingAvailability(availabilityInputs, fallbackSessionLengthMinutes); + if (availability.Count == 0) + { + throw new InvalidOperationException("Select at least one writing day."); + } + } + + private static IReadOnlyList NormaliseWritingAvailability(IReadOnlyList? availabilityInputs, int fallbackSessionLengthMinutes) + { + var inputs = availabilityInputs ?? []; + var availability = new List(); + foreach (var day in WritingDays) + { + var input = inputs.FirstOrDefault(x => string.Equals(x.Day, day, StringComparison.OrdinalIgnoreCase)); + if (input is null || !input.IsAvailable) + { + continue; + } + + var minutes = input.Minutes > 0 ? input.Minutes : fallbackSessionLengthMinutes; + if (!AllowedSessionLengths.Contains(minutes)) + { + throw new InvalidOperationException("Selected writing days must have a valid session length."); + } + + if (TryBuildAvailability(day, minutes, out var dayAvailability)) + { + availability.Add(dayAvailability); + } + } + + return availability; + } + + private static IReadOnlyList BuildWritingDayAvailabilityInputs(IReadOnlyList availability, int fallbackSessionLengthMinutes) + { + var availabilityByDay = availability.ToDictionary(x => x.Day, StringComparer.OrdinalIgnoreCase); + var fallbackMinutes = AllowedSessionLengths.Contains(fallbackSessionLengthMinutes) ? fallbackSessionLengthMinutes : 60; + return WritingDays + .Select(day => + { + availabilityByDay.TryGetValue(day, out var dayAvailability); + return new WritingDayAvailabilityViewModel + { + Day = day, + IsAvailable = dayAvailability is not null, + Minutes = dayAvailability?.Minutes ?? fallbackMinutes + }; + }) + .ToList(); + } + + private static IReadOnlyList ToAvailabilityJson(IReadOnlyList availability) + => availability.Select(x => new { day = x.Day, minutes = x.Minutes }).Cast().ToList(); + + private static bool TryBuildAvailability(string? dayName, int minutes, out WritingDayAvailability availability) + { + availability = default!; + if (string.IsNullOrWhiteSpace(dayName) + || !Enum.TryParse(dayName, ignoreCase: true, out var dayOfWeek) + || !AllowedSessionLengths.Contains(minutes)) + { + return false; + } + + var canonicalDay = WritingDays.FirstOrDefault(day => string.Equals(day, dayName, StringComparison.OrdinalIgnoreCase)); + if (canonicalDay is null) + { + return false; + } + + availability = new WritingDayAvailability(canonicalDay, dayOfWeek, minutes); + return true; + } + + private static IReadOnlyList OrderAvailability(IEnumerable availability) + { + var order = WritingDays.Select((day, index) => new { day, index }).ToDictionary(x => x.day, x => x.index, StringComparer.OrdinalIgnoreCase); + return availability + .OrderBy(x => order.TryGetValue(x.Day, out var index) ? index : int.MaxValue) + .ToList(); + } + + private static string FormatAvailabilitySummary(string? availableDaysJson, int fallbackSessionLengthMinutes) + { + var availability = ParseWritingAvailability(availableDaysJson, fallbackSessionLengthMinutes); + return availability.Count == 0 + ? "-" + : string.Join(", ", availability.Select(x => $"{x.Day[..3]} {FormatMinutes(x.Minutes)}")); + } + + private static string FormatMinutes(int minutes) + { + if (minutes < 60) + { + return $"{minutes}m"; + } + + var hours = minutes / 60; + var remainder = minutes % 60; + return remainder == 0 + ? $"{hours}h" + : $"{hours}.{remainder / 30 * 5}h"; + } + private static void ValidatePlan(WritingPlanViewModel model) { if (model.DeadlineDate.Date < model.StartDate.Date) @@ -3891,57 +4029,173 @@ public sealed class WritingScheduleService( } } - private static IReadOnlySet ParseAvailableDays(string? availableDaysJson) + private static IReadOnlyList ParseWritingAvailability(string? availableDaysJson, int fallbackSessionLengthMinutes) { if (string.IsNullOrWhiteSpace(availableDaysJson)) { - return new HashSet(); + return []; } try { - var dayNames = JsonSerializer.Deserialize>(availableDaysJson) ?? []; - return dayNames - .Select(day => Enum.TryParse(day, ignoreCase: true, out var parsedDay) ? parsedDay : (DayOfWeek?)null) - .Where(day => day.HasValue) - .Select(day => day!.Value) - .ToHashSet(); + using var document = JsonDocument.Parse(availableDaysJson); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + return []; + } + + var fallbackMinutes = AllowedSessionLengths.Contains(fallbackSessionLengthMinutes) + ? fallbackSessionLengthMinutes + : 60; + var availability = new List(); + + foreach (var element in document.RootElement.EnumerateArray()) + { + string? dayName = null; + var minutes = fallbackMinutes; + + if (element.ValueKind == JsonValueKind.String) + { + dayName = element.GetString(); + } + else if (element.ValueKind == JsonValueKind.Object) + { + if (element.TryGetProperty("day", out var dayProperty) && dayProperty.ValueKind == JsonValueKind.String) + { + dayName = dayProperty.GetString(); + } + else if (element.TryGetProperty("Day", out var pascalDayProperty) && pascalDayProperty.ValueKind == JsonValueKind.String) + { + dayName = pascalDayProperty.GetString(); + } + + if (element.TryGetProperty("minutes", out var minutesProperty) && minutesProperty.TryGetInt32(out var parsedMinutes)) + { + minutes = parsedMinutes; + } + else if (element.TryGetProperty("Minutes", out var pascalMinutesProperty) && pascalMinutesProperty.TryGetInt32(out var parsedPascalMinutes)) + { + minutes = parsedPascalMinutes; + } + } + + if (TryBuildAvailability(dayName, minutes, out var dayAvailability) + && !availability.Any(x => x.DayOfWeek == dayAvailability.DayOfWeek)) + { + availability.Add(dayAvailability); + } + } + + return OrderAvailability(availability); } catch (JsonException) { - return new HashSet(); + return []; } } - private static IReadOnlyList GetAvailableDates(DateTime startDate, DateTime deadlineDate, IReadOnlySet availableDays) + private static IReadOnlyList GetAvailableDates(DateTime startDate, DateTime deadlineDate, IReadOnlyList availability) { - var dates = new List(); + var dates = new List(); + var availabilityByDay = availability.ToDictionary(x => x.DayOfWeek); for (var date = startDate.Date; date <= deadlineDate.Date; date = date.AddDays(1)) { - if (availableDays.Contains(date.DayOfWeek)) + if (availabilityByDay.TryGetValue(date.DayOfWeek, out var dayAvailability)) { - dates.Add(date); + dates.Add(new AvailableWritingDate(date, dayAvailability.Minutes)); } } return dates; } - private static IReadOnlyList GetNextAvailableDates(DateTime startDate, IReadOnlySet availableDays, int count) + private static IReadOnlyList GetNextAvailableDates(DateTime startDate, IReadOnlyList availability, IReadOnlyList plannedItems) { var dates = new List(); - for (var date = startDate.Date; dates.Count < count; date = date.AddDays(1)) + var availabilityByDay = availability.ToDictionary(x => x.DayOfWeek); + var maxAvailableMinutes = availability.Count == 0 ? 0 : availability.Max(x => x.Minutes); + if (plannedItems.Any(x => (x.EstimatedMinutes ?? 0) > maxAvailableMinutes)) { - if (availableDays.Contains(date.DayOfWeek)) + throw new InvalidOperationException("One or more schedule items exceed the available session lengths for this plan."); + } + + foreach (var item in plannedItems) + { + var estimatedMinutes = item.EstimatedMinutes ?? 0; + var placed = false; + for (var date = dates.Count == 0 ? startDate.Date : dates[^1].AddDays(1); !placed; date = date.AddDays(1)) { - dates.Add(date); + if (availabilityByDay.TryGetValue(date.DayOfWeek, out var dayAvailability) + && dayAvailability.Minutes >= estimatedMinutes) + { + dates.Add(date); + placed = true; + } } } return dates; } - private static WritingScheduleItem CreateGeneratedItem(int writingPlanId, WritingScheduleScene scene, DateTime scheduledDate) + private static IReadOnlyList BuildGeneratedScheduleItems(int writingPlanId, IReadOnlyList scenes, IReadOnlyList availableDates) + { + var items = new List(); + var dateIndex = 0; + + foreach (var scene in scenes) + { + var task = CreateTaskEstimate(scene); + var taskDates = new List(); + var remainingMinutes = task.EstimatedMinutes; + + while (remainingMinutes > 0) + { + if (dateIndex >= availableDates.Count) + { + throw new InvalidOperationException("Insufficient available writing sessions before the selected deadline."); + } + + var availableDate = availableDates[dateIndex++]; + taskDates.Add(availableDate); + remainingMinutes -= Math.Min(remainingMinutes, availableDate.Minutes); + } + + var partCount = taskDates.Count; + var remainingTaskMinutes = task.EstimatedMinutes; + var assignedWords = 0; + + for (var partIndex = 0; partIndex < partCount; partIndex++) + { + var partMinutes = Math.Min(remainingTaskMinutes, taskDates[partIndex].Minutes); + remainingTaskMinutes -= partMinutes; + int? targetWords = null; + + if (task.TargetWords.HasValue) + { + targetWords = partIndex == partCount - 1 + ? task.TargetWords.Value - assignedWords + : (int)Math.Round(task.TargetWords.Value * (partMinutes / (double)task.EstimatedMinutes), MidpointRounding.AwayFromZero); + assignedWords += targetWords.Value; + } + + items.Add(new WritingScheduleItem + { + WritingPlanID = writingPlanId, + SceneID = task.Scene.SceneID, + ScheduledDate = taskDates[partIndex].Date, + TaskType = task.TaskType, + EstimatedMinutes = partMinutes, + TargetWords = targetWords, + ScheduleStatus = "Planned", + Notes = partCount > 1 ? $"Part {partIndex + 1} of {partCount}" : null + }); + } + } + + return items; + } + + private static ScheduleTaskEstimate CreateTaskEstimate(WritingScheduleScene scene) { var (taskType, estimatedMinutes, targetWords) = scene.RevisionStatusName switch { @@ -3951,16 +4205,7 @@ public sealed class WritingScheduleService( _ => throw new InvalidOperationException("Scene revision status is not schedulable.") }; - return new WritingScheduleItem - { - WritingPlanID = writingPlanId, - SceneID = scene.SceneID, - ScheduledDate = scheduledDate, - TaskType = taskType, - EstimatedMinutes = estimatedMinutes, - TargetWords = targetWords, - ScheduleStatus = "Planned" - }; + return new ScheduleTaskEstimate(scene, taskType, estimatedMinutes, targetWords); } private static int EstimateDraftMinutes(int? estimatedWordCount) diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 2f02a8d..f87d852 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -361,6 +361,13 @@ public sealed class WritingPlansManagementViewModel public IReadOnlyList Plans { get; set; } = []; } +public sealed class WritingDayAvailabilityViewModel +{ + public string Day { get; set; } = string.Empty; + public bool IsAvailable { get; set; } + public int Minutes { get; set; } = 60; +} + public sealed class WritingPlanSettingsEditViewModel { public int WritingPlanID { get; set; } @@ -382,10 +389,12 @@ public sealed class WritingPlanSettingsEditViewModel public int SessionLengthMinutes { get; set; } public List SelectedWritingDays { get; set; } = []; + public List WritingDayAvailability { get; set; } = []; public bool IncludeBlockedScenes { get; set; } public string RebalanceMode { get; set; } = "Ask"; public bool IsActive { get; set; } public IReadOnlyList RebalanceModeOptions { get; set; } = []; + public IReadOnlyList SessionLengthOptions { get; set; } = []; public IReadOnlyList WritingDayOptions { get; set; } = [ "Monday", @@ -473,6 +482,7 @@ public sealed class WritingScheduleSetupViewModel [Range(1, int.MaxValue, ErrorMessage = "Session length minutes must be greater than zero.")] public int SessionLengthMinutes { get; set; } = 60; + public List WritingDayAvailability { get; set; } = []; public bool IncludeBlockedScenes { get; set; } @@ -483,6 +493,7 @@ public sealed class WritingScheduleSetupViewModel public IReadOnlyList BookOptions { get; set; } = []; public IReadOnlyList GoalTypeOptions { get; set; } = []; public IReadOnlyList RebalanceModeOptions { get; set; } = []; + public IReadOnlyList SessionLengthOptions { get; set; } = []; public IReadOnlyList WritingDayOptions { get; set; } = [ "Monday", diff --git a/PlotLine/Views/Writer/EditWritingPlan.cshtml b/PlotLine/Views/Writer/EditWritingPlan.cshtml index 89a3b98..f2f92de 100644 --- a/PlotLine/Views/Writer/EditWritingPlan.cshtml +++ b/PlotLine/Views/Writer/EditWritingPlan.cshtml @@ -1,7 +1,6 @@ @model WritingPlanSettingsEditViewModel @{ ViewData["Title"] = "Edit Writing Plan"; - var selectedDays = Model.SelectedWritingDays.ToHashSet(StringComparer.OrdinalIgnoreCase); }