Phase 9H: per-day writing session lengths.

This commit is contained in:
Nick Beckley 2026-06-13 12:43:19 +01:00
parent 7fe5a7b9ac
commit ac07a52e27
9 changed files with 403 additions and 121 deletions

View File

@ -52,15 +52,10 @@ public sealed class WriterController(
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> EditWritingPlan(WritingPlanSettingsEditViewModel model) public async Task<IActionResult> EditWritingPlan(WritingPlanSettingsEditViewModel model)
{ {
if (!Request.Form.ContainsKey(nameof(model.SelectedWritingDays))) if (!model.WritingDayAvailability.Any(x => x.IsAvailable))
{
model.SelectedWritingDays = [];
}
if (!model.SelectedWritingDays.Any())
{ {
ModelState.AddModelError(string.Empty, "Select at least one writing day."); 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) if (model.DeadlineDate.Date < model.StartDate.Date)
@ -114,15 +109,10 @@ public sealed class WriterController(
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> ScheduleSetup(WritingScheduleSetupViewModel model) public async Task<IActionResult> ScheduleSetup(WritingScheduleSetupViewModel model)
{ {
if (!Request.Form.ContainsKey(nameof(model.SelectedWritingDays))) if (!model.WritingDayAvailability.Any(x => x.IsAvailable))
{
model.SelectedWritingDays = [];
}
if (!model.SelectedWritingDays.Any())
{ {
ModelState.AddModelError(string.Empty, "Select at least one writing day."); 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) if (model.DeadlineDate.Date < model.StartDate.Date)

View File

@ -1030,6 +1030,7 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa
wp.GoalType, wp.GoalType,
wp.StartDate, wp.StartDate,
wp.DeadlineDate, wp.DeadlineDate,
wp.AvailableDaysJson,
wp.SessionLengthMinutes, wp.SessionLengthMinutes,
wp.IsActive, wp.IsActive,
SUM(CASE WHEN wsi.ScheduleStatus = N'Planned' THEN 1 ELSE 0 END) AS PlannedTaskCount, 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.GoalType,
wp.StartDate, wp.StartDate,
wp.DeadlineDate, wp.DeadlineDate,
wp.AvailableDaysJson,
wp.SessionLengthMinutes, wp.SessionLengthMinutes,
wp.IsActive wp.IsActive
ORDER BY wp.IsActive DESC, wp.DeadlineDate, wp.PlanName, wp.WritingPlanID; ORDER BY wp.IsActive DESC, wp.DeadlineDate, wp.PlanName, wp.WritingPlanID;
@ -1182,6 +1184,7 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa
wsi.EstimatedMinutes, wsi.EstimatedMinutes,
wsi.TargetWords, wsi.TargetWords,
wsi.ScheduleStatus, wsi.ScheduleStatus,
wsi.Notes,
COALESCE(sw.IsBlocked, 0) AS IsBlocked COALESCE(sw.IsBlocked, 0) AS IsBlocked
FROM dbo.WritingScheduleItems wsi FROM dbo.WritingScheduleItems wsi
INNER JOIN dbo.Scenes s ON s.SceneID = wsi.SceneID INNER JOIN dbo.Scenes s ON s.SceneID = wsi.SceneID

View File

@ -1644,7 +1644,9 @@ public sealed class WritingPlanManagementItem
public string GoalType { get; set; } = string.Empty; public string GoalType { get; set; } = string.Empty;
public DateTime StartDate { get; set; } public DateTime StartDate { get; set; }
public DateTime DeadlineDate { get; set; } public DateTime DeadlineDate { get; set; }
public string AvailableDaysJson { get; set; } = string.Empty;
public int SessionLengthMinutes { get; set; } public int SessionLengthMinutes { get; set; }
public string AvailabilitySummary { get; set; } = string.Empty;
public bool IsActive { get; set; } public bool IsActive { get; set; }
public int PlannedTaskCount { get; set; } public int PlannedTaskCount { get; set; }
public DateTime? ProjectedFinishDate { get; set; } public DateTime? ProjectedFinishDate { get; set; }
@ -1666,6 +1668,7 @@ public sealed class WritingSchedulePreviewItem
public int? EstimatedMinutes { get; set; } public int? EstimatedMinutes { get; set; }
public int? TargetWords { get; set; } public int? TargetWords { get; set; }
public string ScheduleStatus { get; set; } = string.Empty; public string ScheduleStatus { get; set; } = string.Empty;
public string? Notes { get; set; }
public bool IsBlocked { get; set; } public bool IsBlocked { get; set; }
} }

View File

@ -3300,6 +3300,25 @@ public sealed class WritingScheduleService(
IBookRepository books, IBookRepository books,
ICurrentUserService currentUser) : IWritingScheduleService ICurrentUserService currentUser) : IWritingScheduleService
{ {
private static readonly IReadOnlyList<string> WritingDays =
[
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
];
private static readonly IReadOnlyList<int> 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<IReadOnlyList<WritingPlanViewModel>> GetWritingPlansByUserAsync(int userId) public async Task<IReadOnlyList<WritingPlanViewModel>> GetWritingPlansByUserAsync(int userId)
{ {
var plans = await writingSchedule.GetWritingPlansByUserAsync(userId); var plans = await writingSchedule.GetWritingPlansByUserAsync(userId);
@ -3379,8 +3398,8 @@ public sealed class WritingScheduleService(
var planViewModel = ToViewModel(plan); var planViewModel = ToViewModel(plan);
ValidatePlan(planViewModel); ValidatePlan(planViewModel);
var availableDays = ParseAvailableDays(plan.AvailableDaysJson); var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes);
if (availableDays.Count == 0) if (availability.Count == 0)
{ {
throw new InvalidOperationException("Available days must include at least one valid day."); throw new InvalidOperationException("Available days must include at least one valid day.");
} }
@ -3392,15 +3411,8 @@ public sealed class WritingScheduleService(
return []; return [];
} }
var scheduledDates = GetAvailableDates(plan.StartDate, plan.DeadlineDate, availableDays); var availableDates = GetAvailableDates(plan.StartDate, plan.DeadlineDate, availability);
if (scheduledDates.Count < scenes.Count) var items = BuildGeneratedScheduleItems(plan.WritingPlanID, scenes, availableDates);
{
throw new InvalidOperationException("Insufficient available writing sessions before the selected deadline.");
}
var items = scenes
.Select((scene, index) => CreateGeneratedItem(plan.WritingPlanID, scene, scheduledDates[index]))
.ToList();
await writingSchedule.ReplaceScheduleItemsAsync(plan.WritingPlanID, items); await writingSchedule.ReplaceScheduleItemsAsync(plan.WritingPlanID, items);
@ -3456,6 +3468,11 @@ public sealed class WritingScheduleService(
} }
var plans = await writingSchedule.GetWritingPlansForManagementAsync(currentUser.UserId.Value); 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 }; return new WritingPlansManagementViewModel { Plans = plans };
} }
@ -3464,8 +3481,9 @@ public sealed class WritingScheduleService(
var plan = await GetPlanForCurrentUserAsync(writingPlanId); var plan = await GetPlanForCurrentUserAsync(writingPlanId);
var project = await projects.GetAsync(plan.ProjectID); var project = await projects.GetAsync(plan.ProjectID);
var book = plan.BookID.HasValue ? await books.GetAsync(plan.BookID.Value) : null; var book = plan.BookID.HasValue ? await books.GetAsync(plan.BookID.Value) : null;
var selectedDays = ParseAvailableDays(plan.AvailableDaysJson) var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes);
.Select(x => x.ToString()) var selectedDays = availability
.Select(x => x.Day)
.ToList(); .ToList();
var model = new WritingPlanSettingsEditViewModel var model = new WritingPlanSettingsEditViewModel
@ -3479,6 +3497,7 @@ public sealed class WritingScheduleService(
DeadlineDate = plan.DeadlineDate.Date, DeadlineDate = plan.DeadlineDate.Date,
SessionLengthMinutes = plan.SessionLengthMinutes, SessionLengthMinutes = plan.SessionLengthMinutes,
SelectedWritingDays = selectedDays, SelectedWritingDays = selectedDays,
WritingDayAvailability = BuildWritingDayAvailabilityInputs(availability, plan.SessionLengthMinutes).ToList(),
IncludeBlockedScenes = plan.IncludeBlockedScenes, IncludeBlockedScenes = plan.IncludeBlockedScenes,
RebalanceMode = plan.RebalanceMode, RebalanceMode = plan.RebalanceMode,
IsActive = plan.IsActive IsActive = plan.IsActive
@ -3491,16 +3510,16 @@ public sealed class WritingScheduleService(
{ {
var plan = await GetPlanForCurrentUserAsync(model.WritingPlanID); var plan = await GetPlanForCurrentUserAsync(model.WritingPlanID);
ValidatePlanSettings(model); ValidatePlanSettings(model);
var writingDays = NormaliseWritingDays(model.SelectedWritingDays); var availability = NormaliseWritingAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
if (writingDays.Count == 0) if (availability.Count == 0)
{ {
throw new InvalidOperationException("Select at least one writing day."); throw new InvalidOperationException("Select at least one writing day.");
} }
plan.PlanName = model.PlanName.Trim(); plan.PlanName = model.PlanName.Trim();
plan.DeadlineDate = model.DeadlineDate.Date; plan.DeadlineDate = model.DeadlineDate.Date;
plan.AvailableDaysJson = JsonSerializer.Serialize(writingDays); plan.AvailableDaysJson = JsonSerializer.Serialize(ToAvailabilityJson(availability));
plan.SessionLengthMinutes = model.SessionLengthMinutes; plan.SessionLengthMinutes = availability.First().Minutes;
plan.IncludeBlockedScenes = model.IncludeBlockedScenes; plan.IncludeBlockedScenes = model.IncludeBlockedScenes;
plan.RebalanceMode = model.RebalanceMode; plan.RebalanceMode = model.RebalanceMode;
plan.IsActive = model.IsActive; plan.IsActive = model.IsActive;
@ -3530,6 +3549,13 @@ public sealed class WritingScheduleService(
private static void PopulatePlanSettingsOptions(WritingPlanSettingsEditViewModel model) private static void PopulatePlanSettingsOptions(WritingPlanSettingsEditViewModel model)
{ {
model.RebalanceModeOptions = ScheduleOptionList(["Ask", "Automatic", "Never"], model.RebalanceMode); 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<DayOfWeek>(day), model.SessionLengthMinutes)).ToList(),
model.SessionLengthMinutes).ToList();
}
} }
public async Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId) public async Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId)
@ -3572,6 +3598,13 @@ public sealed class WritingScheduleService(
.ToList(); .ToList();
model.GoalTypeOptions = ScheduleOptionList(["FirstDraft", "Revision", "BetaReady", "Custom"], model.GoalType); model.GoalTypeOptions = ScheduleOptionList(["FirstDraft", "Revision", "BetaReady", "Custom"], model.GoalType);
model.RebalanceModeOptions = ScheduleOptionList(["Ask", "Automatic", "Never"], model.RebalanceMode); 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<DayOfWeek>(day), model.SessionLengthMinutes)).ToList(),
model.SessionLengthMinutes).ToList();
}
} }
public async Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model) public async Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model)
@ -3582,8 +3615,8 @@ public sealed class WritingScheduleService(
} }
ValidateSetup(model); ValidateSetup(model);
var writingDays = NormaliseWritingDays(model.SelectedWritingDays); var availability = NormaliseWritingAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
if (writingDays.Count == 0) if (availability.Count == 0)
{ {
throw new InvalidOperationException("Select at least one writing day."); throw new InvalidOperationException("Select at least one writing day.");
} }
@ -3612,8 +3645,8 @@ public sealed class WritingScheduleService(
GoalType = model.GoalType, GoalType = model.GoalType,
StartDate = model.StartDate.Date, StartDate = model.StartDate.Date,
DeadlineDate = model.DeadlineDate.Date, DeadlineDate = model.DeadlineDate.Date,
AvailableDaysJson = JsonSerializer.Serialize(writingDays), AvailableDaysJson = JsonSerializer.Serialize(ToAvailabilityJson(availability)),
SessionLengthMinutes = model.SessionLengthMinutes, SessionLengthMinutes = availability.First().Minutes,
IncludeBlockedScenes = model.IncludeBlockedScenes, IncludeBlockedScenes = model.IncludeBlockedScenes,
RebalanceMode = model.RebalanceMode, RebalanceMode = model.RebalanceMode,
IsActive = true IsActive = true
@ -3732,13 +3765,13 @@ public sealed class WritingScheduleService(
return new WritingScheduleRebalancePreviewViewModel { WritingPlanID = plan.WritingPlanID }; return new WritingScheduleRebalancePreviewViewModel { WritingPlanID = plan.WritingPlanID };
} }
var availableDays = ParseAvailableDays(plan.AvailableDaysJson); var availability = ParseWritingAvailability(plan.AvailableDaysJson, plan.SessionLengthMinutes);
if (availableDays.Count == 0) if (availability.Count == 0)
{ {
throw new InvalidOperationException("Available days must include at least one valid day."); 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 var changes = plannedItems
.Select((item, index) => new { Item = item, ProposedDate = proposedDates[index] }) .Select((item, index) => new { Item = item, ProposedDate = proposedDates[index] })
.Where(x => x.Item.ScheduledDate.Date != x.ProposedDate.Date) .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."); throw new InvalidOperationException("Deadline date must be on or after the start date.");
} }
if (model.SessionLengthMinutes <= 0) ValidateAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
{
throw new InvalidOperationException("Session length minutes must be greater than zero.");
}
} }
private static void ValidatePlanSettings(WritingPlanSettingsEditViewModel model) 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."); throw new InvalidOperationException("Deadline date must be on or after the start date.");
} }
if (model.SessionLengthMinutes <= 0) ValidateAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
{
throw new InvalidOperationException("Session length minutes must be greater than zero.");
}
}
private static IReadOnlyList<string> NormaliseWritingDays(IEnumerable<string>? selectedDays)
{
var selected = (selectedDays ?? [])
.Where(day => !string.IsNullOrWhiteSpace(day))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return
[
.. new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }
.Where(selected.Contains)
];
} }
private static IReadOnlyList<SelectListItem> ScheduleOptionList(IEnumerable<string> values, string selectedValue) private static IReadOnlyList<SelectListItem> ScheduleOptionList(IEnumerable<string> values, string selectedValue)
=> values.Select(value => new SelectListItem(value, value, string.Equals(value, selectedValue, StringComparison.OrdinalIgnoreCase))).ToList(); => values.Select(value => new SelectListItem(value, value, string.Equals(value, selectedValue, StringComparison.OrdinalIgnoreCase))).ToList();
private static IReadOnlyList<SelectListItem> 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<WritingDayAvailabilityViewModel> availabilityInputs, int fallbackSessionLengthMinutes)
{
var availability = NormaliseWritingAvailability(availabilityInputs, fallbackSessionLengthMinutes);
if (availability.Count == 0)
{
throw new InvalidOperationException("Select at least one writing day.");
}
}
private static IReadOnlyList<WritingDayAvailability> NormaliseWritingAvailability(IReadOnlyList<WritingDayAvailabilityViewModel>? availabilityInputs, int fallbackSessionLengthMinutes)
{
var inputs = availabilityInputs ?? [];
var availability = new List<WritingDayAvailability>();
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<WritingDayAvailabilityViewModel> BuildWritingDayAvailabilityInputs(IReadOnlyList<WritingDayAvailability> 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<object> ToAvailabilityJson(IReadOnlyList<WritingDayAvailability> availability)
=> availability.Select(x => new { day = x.Day, minutes = x.Minutes }).Cast<object>().ToList();
private static bool TryBuildAvailability(string? dayName, int minutes, out WritingDayAvailability availability)
{
availability = default!;
if (string.IsNullOrWhiteSpace(dayName)
|| !Enum.TryParse<DayOfWeek>(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<WritingDayAvailability> OrderAvailability(IEnumerable<WritingDayAvailability> 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) private static void ValidatePlan(WritingPlanViewModel model)
{ {
if (model.DeadlineDate.Date < model.StartDate.Date) if (model.DeadlineDate.Date < model.StartDate.Date)
@ -3891,57 +4029,173 @@ public sealed class WritingScheduleService(
} }
} }
private static IReadOnlySet<DayOfWeek> ParseAvailableDays(string? availableDaysJson) private static IReadOnlyList<WritingDayAvailability> ParseWritingAvailability(string? availableDaysJson, int fallbackSessionLengthMinutes)
{ {
if (string.IsNullOrWhiteSpace(availableDaysJson)) if (string.IsNullOrWhiteSpace(availableDaysJson))
{ {
return new HashSet<DayOfWeek>(); return [];
} }
try try
{ {
var dayNames = JsonSerializer.Deserialize<IReadOnlyList<string>>(availableDaysJson) ?? []; using var document = JsonDocument.Parse(availableDaysJson);
return dayNames if (document.RootElement.ValueKind != JsonValueKind.Array)
.Select(day => Enum.TryParse<DayOfWeek>(day, ignoreCase: true, out var parsedDay) ? parsedDay : (DayOfWeek?)null) {
.Where(day => day.HasValue) return [];
.Select(day => day!.Value) }
.ToHashSet();
var fallbackMinutes = AllowedSessionLengths.Contains(fallbackSessionLengthMinutes)
? fallbackSessionLengthMinutes
: 60;
var availability = new List<WritingDayAvailability>();
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) catch (JsonException)
{ {
return new HashSet<DayOfWeek>(); return [];
} }
} }
private static IReadOnlyList<DateTime> GetAvailableDates(DateTime startDate, DateTime deadlineDate, IReadOnlySet<DayOfWeek> availableDays) private static IReadOnlyList<AvailableWritingDate> GetAvailableDates(DateTime startDate, DateTime deadlineDate, IReadOnlyList<WritingDayAvailability> availability)
{ {
var dates = new List<DateTime>(); var dates = new List<AvailableWritingDate>();
var availabilityByDay = availability.ToDictionary(x => x.DayOfWeek);
for (var date = startDate.Date; date <= deadlineDate.Date; date = date.AddDays(1)) 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; return dates;
} }
private static IReadOnlyList<DateTime> GetNextAvailableDates(DateTime startDate, IReadOnlySet<DayOfWeek> availableDays, int count) private static IReadOnlyList<DateTime> GetNextAvailableDates(DateTime startDate, IReadOnlyList<WritingDayAvailability> availability, IReadOnlyList<WritingSchedulePreviewItem> plannedItems)
{ {
var dates = new List<DateTime>(); var dates = new List<DateTime>();
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; return dates;
} }
private static WritingScheduleItem CreateGeneratedItem(int writingPlanId, WritingScheduleScene scene, DateTime scheduledDate) private static IReadOnlyList<WritingScheduleItem> BuildGeneratedScheduleItems(int writingPlanId, IReadOnlyList<WritingScheduleScene> scenes, IReadOnlyList<AvailableWritingDate> availableDates)
{
var items = new List<WritingScheduleItem>();
var dateIndex = 0;
foreach (var scene in scenes)
{
var task = CreateTaskEstimate(scene);
var taskDates = new List<AvailableWritingDate>();
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 var (taskType, estimatedMinutes, targetWords) = scene.RevisionStatusName switch
{ {
@ -3951,16 +4205,7 @@ public sealed class WritingScheduleService(
_ => throw new InvalidOperationException("Scene revision status is not schedulable.") _ => throw new InvalidOperationException("Scene revision status is not schedulable.")
}; };
return new WritingScheduleItem return new ScheduleTaskEstimate(scene, taskType, estimatedMinutes, targetWords);
{
WritingPlanID = writingPlanId,
SceneID = scene.SceneID,
ScheduledDate = scheduledDate,
TaskType = taskType,
EstimatedMinutes = estimatedMinutes,
TargetWords = targetWords,
ScheduleStatus = "Planned"
};
} }
private static int EstimateDraftMinutes(int? estimatedWordCount) private static int EstimateDraftMinutes(int? estimatedWordCount)

View File

@ -361,6 +361,13 @@ public sealed class WritingPlansManagementViewModel
public IReadOnlyList<WritingPlanManagementItem> Plans { get; set; } = []; public IReadOnlyList<WritingPlanManagementItem> 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 sealed class WritingPlanSettingsEditViewModel
{ {
public int WritingPlanID { get; set; } public int WritingPlanID { get; set; }
@ -382,10 +389,12 @@ public sealed class WritingPlanSettingsEditViewModel
public int SessionLengthMinutes { get; set; } public int SessionLengthMinutes { get; set; }
public List<string> SelectedWritingDays { get; set; } = []; public List<string> SelectedWritingDays { get; set; } = [];
public List<WritingDayAvailabilityViewModel> WritingDayAvailability { get; set; } = [];
public bool IncludeBlockedScenes { get; set; } public bool IncludeBlockedScenes { get; set; }
public string RebalanceMode { get; set; } = "Ask"; public string RebalanceMode { get; set; } = "Ask";
public bool IsActive { get; set; } public bool IsActive { get; set; }
public IReadOnlyList<SelectListItem> RebalanceModeOptions { get; set; } = []; public IReadOnlyList<SelectListItem> RebalanceModeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SessionLengthOptions { get; set; } = [];
public IReadOnlyList<string> WritingDayOptions { get; set; } = public IReadOnlyList<string> WritingDayOptions { get; set; } =
[ [
"Monday", "Monday",
@ -473,6 +482,7 @@ public sealed class WritingScheduleSetupViewModel
[Range(1, int.MaxValue, ErrorMessage = "Session length minutes must be greater than zero.")] [Range(1, int.MaxValue, ErrorMessage = "Session length minutes must be greater than zero.")]
public int SessionLengthMinutes { get; set; } = 60; public int SessionLengthMinutes { get; set; } = 60;
public List<WritingDayAvailabilityViewModel> WritingDayAvailability { get; set; } = [];
public bool IncludeBlockedScenes { get; set; } public bool IncludeBlockedScenes { get; set; }
@ -483,6 +493,7 @@ public sealed class WritingScheduleSetupViewModel
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = []; public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> GoalTypeOptions { get; set; } = []; public IReadOnlyList<SelectListItem> GoalTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> RebalanceModeOptions { get; set; } = []; public IReadOnlyList<SelectListItem> RebalanceModeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SessionLengthOptions { get; set; } = [];
public IReadOnlyList<string> WritingDayOptions { get; set; } = public IReadOnlyList<string> WritingDayOptions { get; set; } =
[ [
"Monday", "Monday",

View File

@ -1,7 +1,6 @@
@model WritingPlanSettingsEditViewModel @model WritingPlanSettingsEditViewModel
@{ @{
ViewData["Title"] = "Edit Writing Plan"; ViewData["Title"] = "Edit Writing Plan";
var selectedDays = Model.SelectedWritingDays.ToHashSet(StringComparer.OrdinalIgnoreCase);
} }
<nav class="breadcrumb-trail" aria-label="Breadcrumb"> <nav class="breadcrumb-trail" aria-label="Breadcrumb">
@ -57,11 +56,7 @@
<span asp-validation-for="DeadlineDate" class="text-danger"></span> <span asp-validation-for="DeadlineDate" class="text-danger"></span>
</div> </div>
<div class="col-md-6"> <input type="hidden" asp-for="SessionLengthMinutes" />
<label asp-for="SessionLengthMinutes" class="form-label">Session Length Minutes</label>
<input asp-for="SessionLengthMinutes" class="form-control" type="number" min="1" />
<span asp-validation-for="SessionLengthMinutes" class="text-danger"></span>
</div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="RebalanceMode" class="form-label">Rebalance Mode</label> <label asp-for="RebalanceMode" class="form-label">Rebalance Mode</label>
@ -70,17 +65,36 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Available Writing Days</label> <label class="form-label">Writing Availability</label>
<div class="writer-checkbox-grid"> <div class="table-responsive">
@foreach (var day in Model.WritingDayOptions) <table class="table align-middle">
{ <thead>
<label> <tr>
<input type="checkbox" name="SelectedWritingDays" value="@day" checked="@selectedDays.Contains(day)" /> <th>Day</th>
@day <th>Available</th>
</label> <th>Session Length</th>
} </tr>
</thead>
<tbody>
@for (var i = 0; i < Model.WritingDayAvailability.Count; i++)
{
<tr>
<td>
@Model.WritingDayAvailability[i].Day
<input type="hidden" asp-for="WritingDayAvailability[i].Day" />
</td>
<td>
<input asp-for="WritingDayAvailability[i].IsAvailable" />
</td>
<td>
<select asp-for="WritingDayAvailability[i].Minutes" asp-items="Model.SessionLengthOptions" class="form-select"></select>
</td>
</tr>
}
</tbody>
</table>
</div> </div>
<span asp-validation-for="SelectedWritingDays" class="text-danger"></span> <span asp-validation-for="WritingDayAvailability" class="text-danger"></span>
</div> </div>
<div class="col-12 writer-checkbox-grid"> <div class="col-12 writer-checkbox-grid">

View File

@ -155,6 +155,7 @@ else
<th>Estimated Minutes</th> <th>Estimated Minutes</th>
<th>Target Words</th> <th>Target Words</th>
<th>Schedule Status</th> <th>Schedule Status</th>
<th>Notes</th>
<th>Blocked</th> <th>Blocked</th>
</tr> </tr>
</thead> </thead>
@ -171,6 +172,7 @@ else
<td>@(item.EstimatedMinutes?.ToString() ?? "-")</td> <td>@(item.EstimatedMinutes?.ToString() ?? "-")</td>
<td>@(item.TargetWords?.ToString() ?? "-")</td> <td>@(item.TargetWords?.ToString() ?? "-")</td>
<td>@item.ScheduleStatus</td> <td>@item.ScheduleStatus</td>
<td>@(item.Notes ?? "-")</td>
<td>@(item.IsBlocked ? "Yes" : "No")</td> <td>@(item.IsBlocked ? "Yes" : "No")</td>
</tr> </tr>
} }

View File

@ -1,7 +1,6 @@
@model WritingScheduleSetupViewModel @model WritingScheduleSetupViewModel
@{ @{
ViewData["Title"] = "Schedule Setup"; ViewData["Title"] = "Schedule Setup";
var selectedDays = Model.SelectedWritingDays.ToHashSet(StringComparer.OrdinalIgnoreCase);
} }
<nav class="breadcrumb-trail" aria-label="Breadcrumb"> <nav class="breadcrumb-trail" aria-label="Breadcrumb">
@ -62,11 +61,7 @@
<span asp-validation-for="DeadlineDate" class="text-danger"></span> <span asp-validation-for="DeadlineDate" class="text-danger"></span>
</div> </div>
<div class="col-md-6"> <input type="hidden" asp-for="SessionLengthMinutes" />
<label asp-for="SessionLengthMinutes" class="form-label">Session Length Minutes</label>
<input asp-for="SessionLengthMinutes" class="form-control" type="number" min="1" />
<span asp-validation-for="SessionLengthMinutes" class="text-danger"></span>
</div>
<div class="col-md-6"> <div class="col-md-6">
<label asp-for="RebalanceMode" class="form-label">Rebalance Mode</label> <label asp-for="RebalanceMode" class="form-label">Rebalance Mode</label>
@ -75,17 +70,36 @@
</div> </div>
<div class="col-12"> <div class="col-12">
<label class="form-label">Writing Days</label> <label class="form-label">Writing Availability</label>
<div class="writer-checkbox-grid"> <div class="table-responsive">
@foreach (var day in Model.WritingDayOptions) <table class="table align-middle">
{ <thead>
<label> <tr>
<input type="checkbox" name="SelectedWritingDays" value="@day" checked="@selectedDays.Contains(day)" /> <th>Day</th>
@day <th>Available</th>
</label> <th>Session Length</th>
} </tr>
</thead>
<tbody>
@for (var i = 0; i < Model.WritingDayAvailability.Count; i++)
{
<tr>
<td>
@Model.WritingDayAvailability[i].Day
<input type="hidden" asp-for="WritingDayAvailability[i].Day" />
</td>
<td>
<input asp-for="WritingDayAvailability[i].IsAvailable" />
</td>
<td>
<select asp-for="WritingDayAvailability[i].Minutes" asp-items="Model.SessionLengthOptions" class="form-select"></select>
</td>
</tr>
}
</tbody>
</table>
</div> </div>
<span asp-validation-for="SelectedWritingDays" class="text-danger"></span> <span asp-validation-for="WritingDayAvailability" class="text-danger"></span>
</div> </div>
<div class="col-12 writer-checkbox-grid"> <div class="col-12 writer-checkbox-grid">

View File

@ -57,7 +57,7 @@
<th>Goal Type</th> <th>Goal Type</th>
<th>Start Date</th> <th>Start Date</th>
<th>Deadline Date</th> <th>Deadline Date</th>
<th>Session Length</th> <th>Availability</th>
<th>Active</th> <th>Active</th>
<th>Planned Tasks</th> <th>Planned Tasks</th>
<th>Projected Finish Date</th> <th>Projected Finish Date</th>
@ -74,7 +74,7 @@
<td>@plan.GoalType</td> <td>@plan.GoalType</td>
<td>@plan.StartDate.ToString("dd MMM yyyy")</td> <td>@plan.StartDate.ToString("dd MMM yyyy")</td>
<td>@plan.DeadlineDate.ToString("dd MMM yyyy")</td> <td>@plan.DeadlineDate.ToString("dd MMM yyyy")</td>
<td>@plan.SessionLengthMinutes minutes</td> <td>@plan.AvailabilitySummary</td>
<td>@(plan.IsActive ? "Yes" : "No")</td> <td>@(plan.IsActive ? "Yes" : "No")</td>
<td>@plan.PlannedTaskCount</td> <td>@plan.PlannedTaskCount</td>
<td>@(plan.ProjectedFinishDate?.ToString("dd MMM yyyy") ?? "-")</td> <td>@(plan.ProjectedFinishDate?.ToString("dd MMM yyyy") ?? "-")</td>