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]
|
[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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user