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]
public async Task<IActionResult> 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<IActionResult> 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)

View File

@ -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

View File

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

View File

@ -3300,6 +3300,25 @@ public sealed class WritingScheduleService(
IBookRepository books,
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)
{
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<DayOfWeek>(day), model.SessionLengthMinutes)).ToList(),
model.SessionLengthMinutes).ToList();
}
}
public async Task<WritingScheduleSetupViewModel> 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<DayOfWeek>(day), model.SessionLengthMinutes)).ToList(),
model.SessionLengthMinutes).ToList();
}
}
public async Task<WritingScheduleSetupResult> 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<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)
];
ValidateAvailability(model.WritingDayAvailability, model.SessionLengthMinutes);
}
private static IReadOnlyList<SelectListItem> ScheduleOptionList(IEnumerable<string> values, string selectedValue)
=> 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)
{
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))
{
return new HashSet<DayOfWeek>();
return [];
}
try
{
var dayNames = JsonSerializer.Deserialize<IReadOnlyList<string>>(availableDaysJson) ?? [];
return dayNames
.Select(day => Enum.TryParse<DayOfWeek>(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<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)
{
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))
{
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<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>();
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<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
{
@ -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)

View File

@ -361,6 +361,13 @@ public sealed class WritingPlansManagementViewModel
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 int WritingPlanID { get; set; }
@ -382,10 +389,12 @@ public sealed class WritingPlanSettingsEditViewModel
public int SessionLengthMinutes { get; set; }
public List<string> SelectedWritingDays { get; set; } = [];
public List<WritingDayAvailabilityViewModel> WritingDayAvailability { get; set; } = [];
public bool IncludeBlockedScenes { get; set; }
public string RebalanceMode { get; set; } = "Ask";
public bool IsActive { get; set; }
public IReadOnlyList<SelectListItem> RebalanceModeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SessionLengthOptions { get; set; } = [];
public IReadOnlyList<string> 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<WritingDayAvailabilityViewModel> WritingDayAvailability { get; set; } = [];
public bool IncludeBlockedScenes { get; set; }
@ -483,6 +493,7 @@ public sealed class WritingScheduleSetupViewModel
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> GoalTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> RebalanceModeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SessionLengthOptions { get; set; } = [];
public IReadOnlyList<string> WritingDayOptions { get; set; } =
[
"Monday",

View File

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

View File

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

View File

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

View File

@ -57,7 +57,7 @@
<th>Goal Type</th>
<th>Start Date</th>
<th>Deadline Date</th>
<th>Session Length</th>
<th>Availability</th>
<th>Active</th>
<th>Planned Tasks</th>
<th>Projected Finish Date</th>
@ -74,7 +74,7 @@
<td>@plan.GoalType</td>
<td>@plan.StartDate.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.PlannedTaskCount</td>
<td>@(plan.ProjectedFinishDate?.ToString("dd MMM yyyy") ?? "-")</td>