Phase 9H: per-day writing session lengths.
This commit is contained in:
parent
7fe5a7b9ac
commit
ac07a52e27
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user