From 8f006874aca8135412044289bd6f57e60ee63b02 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 12 Jun 2026 22:46:18 +0100 Subject: [PATCH] Phase 9G: Writing Plan Management --- PlotLine/Controllers/WriterController.cs | 160 ++++++++++++++++++- PlotLine/Data/Repositories.cs | 43 +++++ PlotLine/Models/CoreModels.cs | 18 +++ PlotLine/Services/CoreServices.cs | 144 ++++++++++++++++- PlotLine/ViewModels/CoreViewModels.cs | 42 +++++ PlotLine/Views/Writer/EditWritingPlan.cshtml | 105 ++++++++++++ PlotLine/Views/Writer/Index.cshtml | 1 + PlotLine/Views/Writer/SchedulePreview.cshtml | 27 +++- PlotLine/Views/Writer/WritingPlans.cshtml | 115 +++++++++++++ 9 files changed, 645 insertions(+), 10 deletions(-) create mode 100644 PlotLine/Views/Writer/EditWritingPlan.cshtml create mode 100644 PlotLine/Views/Writer/WritingPlans.cshtml diff --git a/PlotLine/Controllers/WriterController.cs b/PlotLine/Controllers/WriterController.cs index a022875..67faa09 100644 --- a/PlotLine/Controllers/WriterController.cs +++ b/PlotLine/Controllers/WriterController.cs @@ -29,6 +29,65 @@ public sealed class WriterController( return View(model); } + public async Task WritingPlans() + { + var model = await writingSchedule.GetWritingPlansForManagementAsync(); + return View(model); + } + + public async Task EditWritingPlan(int id) + { + try + { + var model = await writingSchedule.GetWritingPlanSettingsAsync(id); + return View(model); + } + catch (InvalidOperationException) + { + return NotFound(); + } + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task EditWritingPlan(WritingPlanSettingsEditViewModel model) + { + if (!Request.Form.ContainsKey(nameof(model.SelectedWritingDays))) + { + model.SelectedWritingDays = []; + } + + if (!model.SelectedWritingDays.Any()) + { + ModelState.AddModelError(string.Empty, "Select at least one writing day."); + ModelState.AddModelError(nameof(model.SelectedWritingDays), "Select at least one writing day."); + } + + if (model.DeadlineDate.Date < model.StartDate.Date) + { + ModelState.AddModelError(nameof(model.DeadlineDate), "Deadline date must be on or after the start date."); + } + + if (!ModelState.IsValid) + { + await PopulatePlanSettingsDisplayAsync(model); + return View(model); + } + + try + { + await writingSchedule.UpdateWritingPlanSettingsAsync(model); + TempData["WritingPlansMessage"] = "Writing plan updated."; + return RedirectToAction(nameof(WritingPlans)); + } + catch (InvalidOperationException ex) + { + ModelState.AddModelError(string.Empty, ex.Message); + await PopulatePlanSettingsDisplayAsync(model); + return View(model); + } + } + public async Task PreviewRebalance(int writingPlanId) { var model = await writingSchedule.GetSchedulePreviewAsync(writingPlanId); @@ -120,8 +179,20 @@ public sealed class WriterController( [ValidateAntiForgeryToken] public async Task DeleteGeneratedSchedule(int writingPlanId) { - await writingSchedule.DeleteScheduleAsync(writingPlanId); - TempData["SchedulePreviewMessage"] = "Generated schedule deleted."; + try + { + await writingSchedule.DeleteScheduleAsync(writingPlanId); + TempData["SchedulePreviewMessage"] = "Generated schedule deleted."; + } + catch (InvalidOperationException ex) + { + TempData["SchedulePreviewError"] = ex.Message; + } + catch + { + TempData["SchedulePreviewError"] = "Generated schedule could not be deleted."; + } + return RedirectToAction(nameof(SchedulePreview), new { writingPlanId }); } @@ -142,6 +213,74 @@ public sealed class WriterController( return RedirectToAction(nameof(SchedulePreview), new { writingPlanId }); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ActivateWritingPlan(int writingPlanId) + { + try + { + await writingSchedule.ActivateWritingPlanAsync(writingPlanId); + TempData["WritingPlansMessage"] = "Writing plan activated."; + } + catch (InvalidOperationException ex) + { + TempData["WritingPlansError"] = ex.Message; + } + + return RedirectToAction(nameof(WritingPlans)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task DeactivateWritingPlan(int writingPlanId) + { + try + { + await writingSchedule.DeactivateWritingPlanAsync(writingPlanId); + TempData["WritingPlansMessage"] = "Writing plan deactivated."; + } + catch (InvalidOperationException ex) + { + TempData["WritingPlansError"] = ex.Message; + } + + return RedirectToAction(nameof(WritingPlans)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task DeleteWritingPlan(int writingPlanId) + { + try + { + await writingSchedule.DeleteWritingPlanAsync(writingPlanId); + TempData["WritingPlansMessage"] = "Writing plan deleted."; + } + catch (InvalidOperationException ex) + { + TempData["WritingPlansError"] = ex.Message; + } + + return RedirectToAction(nameof(WritingPlans)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task RegenerateWritingPlanSchedule(int writingPlanId) + { + try + { + var items = await writingSchedule.RegenerateScheduleAsync(writingPlanId); + TempData["SchedulePreviewMessage"] = $"{items.Count} item{(items.Count == 1 ? "" : "s")} generated."; + } + catch (InvalidOperationException ex) + { + TempData["SchedulePreviewError"] = ex.Message; + } + + return RedirectToAction(nameof(SchedulePreview), new { writingPlanId }); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task MarkScheduleItemComplete(int writingScheduleItemId, int? projectId) @@ -171,4 +310,21 @@ public sealed class WriterController( await writerWorkspace.SaveChapterGoalAsync(model); return RedirectToAction(nameof(Chapter), new { id = model.ChapterID }); } + + private async Task PopulatePlanSettingsDisplayAsync(WritingPlanSettingsEditViewModel model) + { + try + { + var existing = await writingSchedule.GetWritingPlanSettingsAsync(model.WritingPlanID); + model.ProjectName = existing.ProjectName; + model.BookName = existing.BookName; + model.GoalType = existing.GoalType; + model.StartDate = existing.StartDate; + model.RebalanceModeOptions = existing.RebalanceModeOptions; + } + catch (InvalidOperationException) + { + // The service will surface the ownership/not-found validation message on submit. + } + } } diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index efc6faf..6bc3e9e 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -286,6 +286,7 @@ public interface IWritingScheduleRepository Task UpdateScheduleItemAsync(WritingScheduleItem item); Task DeleteScheduleItemAsync(int writingScheduleItemId); Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc); + Task> GetWritingPlansForManagementAsync(int userId); Task> GetSchedulingScenesAsync(int projectId, int? bookId, bool includeBlockedScenes); Task ReplaceScheduleItemsAsync(int writingPlanId, IReadOnlyList items); Task> GetWritingPlanSummariesByUserAsync(int userId); @@ -1014,6 +1015,48 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa commandType: CommandType.StoredProcedure); } + public async Task> GetWritingPlansForManagementAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + SELECT wp.WritingPlanID, + wp.UserID, + wp.ProjectID, + p.ProjectName, + wp.BookID, + b.BookTitle, + wp.PlanName, + wp.GoalType, + wp.StartDate, + wp.DeadlineDate, + wp.SessionLengthMinutes, + wp.IsActive, + SUM(CASE WHEN wsi.ScheduleStatus = N'Planned' THEN 1 ELSE 0 END) AS PlannedTaskCount, + MAX(CASE WHEN wsi.ScheduleStatus = N'Planned' THEN wsi.ScheduledDate ELSE NULL END) AS ProjectedFinishDate + FROM dbo.WritingPlans wp + INNER JOIN dbo.Projects p ON p.ProjectID = wp.ProjectID + LEFT JOIN dbo.Books b ON b.BookID = wp.BookID + LEFT JOIN dbo.WritingScheduleItems wsi ON wsi.WritingPlanID = wp.WritingPlanID + WHERE wp.UserID = @UserID + GROUP BY wp.WritingPlanID, + wp.UserID, + wp.ProjectID, + p.ProjectName, + wp.BookID, + b.BookTitle, + wp.PlanName, + wp.GoalType, + wp.StartDate, + wp.DeadlineDate, + wp.SessionLengthMinutes, + wp.IsActive + ORDER BY wp.IsActive DESC, wp.DeadlineDate, wp.PlanName, wp.WritingPlanID; + """, + new { UserID = userId }); + return rows.ToList(); + } + public async Task> GetSchedulingScenesAsync(int projectId, int? bookId, bool includeBlockedScenes) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 8b4f3f3..a07ce8f 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -1632,6 +1632,24 @@ public sealed class WritingPlanSummary public bool IsActive { get; set; } } +public sealed class WritingPlanManagementItem +{ + public int WritingPlanID { get; set; } + public int UserID { get; set; } + public int ProjectID { get; set; } + public string ProjectName { get; set; } = string.Empty; + public int? BookID { get; set; } + public string? BookTitle { get; set; } + public string PlanName { get; set; } = string.Empty; + public string GoalType { get; set; } = string.Empty; + public DateTime StartDate { get; set; } + public DateTime DeadlineDate { get; set; } + public int SessionLengthMinutes { get; set; } + public bool IsActive { get; set; } + public int PlannedTaskCount { get; set; } + public DateTime? ProjectedFinishDate { get; set; } +} + public sealed class WritingSchedulePreviewItem { public int WritingScheduleItemID { get; set; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index bd0eb78..54e12a3 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -274,6 +274,12 @@ public interface IWritingScheduleService Task> GenerateSchedule(int writingPlanId); Task GetSchedulePreviewAsync(int? writingPlanId); Task DeleteScheduleAsync(int writingPlanId); + Task GetWritingPlansForManagementAsync(); + Task GetWritingPlanSettingsAsync(int writingPlanId); + Task UpdateWritingPlanSettingsAsync(WritingPlanSettingsEditViewModel model); + Task ActivateWritingPlanAsync(int writingPlanId); + Task DeactivateWritingPlanAsync(int writingPlanId); + Task> RegenerateScheduleAsync(int writingPlanId); Task GetScheduleSetupAsync(int? projectId); Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model); Task CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model); @@ -3318,11 +3324,18 @@ public sealed class WritingScheduleService( return writingSchedule.UpdateWritingPlanAsync(ToModel(model)); } - public Task DeleteWritingPlanAsync(int writingPlanId) => - writingSchedule.DeleteWritingPlanAsync(writingPlanId); + public async Task DeleteWritingPlanAsync(int writingPlanId) + { + await GetPlanForCurrentUserAsync(writingPlanId); + await writingSchedule.DeleteScheduleItemsByPlanAsync(writingPlanId); + await writingSchedule.DeleteWritingPlanAsync(writingPlanId); + } - public Task SetWritingPlanActiveAsync(int writingPlanId, bool isActive) => - writingSchedule.SetWritingPlanActiveAsync(writingPlanId, isActive); + public async Task SetWritingPlanActiveAsync(int writingPlanId, bool isActive) + { + await GetPlanForCurrentUserAsync(writingPlanId); + await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, isActive); + } public async Task> GetScheduleItemsByPlanAsync(int writingPlanId) { @@ -3358,6 +3371,11 @@ public sealed class WritingScheduleService( { var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId) ?? throw new InvalidOperationException("Writing plan not found."); + if (currentUser.UserId.HasValue && plan.UserID != currentUser.UserId.Value) + { + throw new InvalidOperationException("Writing plan not found."); + } + var planViewModel = ToViewModel(plan); ValidatePlan(planViewModel); @@ -3430,6 +3448,90 @@ public sealed class WritingScheduleService( } } + public async Task GetWritingPlansForManagementAsync() + { + if (!currentUser.UserId.HasValue) + { + return new WritingPlansManagementViewModel(); + } + + var plans = await writingSchedule.GetWritingPlansForManagementAsync(currentUser.UserId.Value); + return new WritingPlansManagementViewModel { Plans = plans }; + } + + public async Task GetWritingPlanSettingsAsync(int writingPlanId) + { + 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()) + .ToList(); + + var model = new WritingPlanSettingsEditViewModel + { + WritingPlanID = plan.WritingPlanID, + PlanName = plan.PlanName, + ProjectName = project?.ProjectName ?? string.Empty, + BookName = book?.BookTitle ?? "All Books", + GoalType = plan.GoalType, + StartDate = plan.StartDate.Date, + DeadlineDate = plan.DeadlineDate.Date, + SessionLengthMinutes = plan.SessionLengthMinutes, + SelectedWritingDays = selectedDays, + IncludeBlockedScenes = plan.IncludeBlockedScenes, + RebalanceMode = plan.RebalanceMode, + IsActive = plan.IsActive + }; + PopulatePlanSettingsOptions(model); + return model; + } + + public async Task UpdateWritingPlanSettingsAsync(WritingPlanSettingsEditViewModel model) + { + var plan = await GetPlanForCurrentUserAsync(model.WritingPlanID); + ValidatePlanSettings(model); + var writingDays = NormaliseWritingDays(model.SelectedWritingDays); + if (writingDays.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.IncludeBlockedScenes = model.IncludeBlockedScenes; + plan.RebalanceMode = model.RebalanceMode; + plan.IsActive = model.IsActive; + + await writingSchedule.UpdateWritingPlanAsync(plan); + } + + public async Task ActivateWritingPlanAsync(int writingPlanId) + { + await GetPlanForCurrentUserAsync(writingPlanId); + await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, true); + } + + public async Task DeactivateWritingPlanAsync(int writingPlanId) + { + await GetPlanForCurrentUserAsync(writingPlanId); + await writingSchedule.SetWritingPlanActiveAsync(writingPlanId, false); + } + + public async Task> RegenerateScheduleAsync(int writingPlanId) + { + await GetPlanForCurrentUserAsync(writingPlanId); + await writingSchedule.DeleteScheduleItemsByPlanAsync(writingPlanId); + return await GenerateSchedule(writingPlanId); + } + + private static void PopulatePlanSettingsOptions(WritingPlanSettingsEditViewModel model) + { + model.RebalanceModeOptions = ScheduleOptionList(["Ask", "Automatic", "Never"], model.RebalanceMode); + } + public async Task GetScheduleSetupAsync(int? projectId) { var projectRows = currentUser.UserId.HasValue @@ -3690,6 +3792,22 @@ public sealed class WritingScheduleService( return plan; } + private async Task GetPlanForCurrentUserAsync(int writingPlanId) + { + if (!currentUser.UserId.HasValue) + { + throw new InvalidOperationException("Sign in before managing a writing schedule."); + } + + var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId); + if (plan is null || plan.UserID != currentUser.UserId.Value) + { + throw new InvalidOperationException("Writing plan not found."); + } + + return plan; + } + private static void ValidateSetup(WritingScheduleSetupViewModel model) { if (string.IsNullOrWhiteSpace(model.PlanName)) @@ -3713,6 +3831,24 @@ public sealed class WritingScheduleService( } } + private static void ValidatePlanSettings(WritingPlanSettingsEditViewModel model) + { + if (string.IsNullOrWhiteSpace(model.PlanName)) + { + throw new InvalidOperationException("Plan name is required."); + } + + if (model.DeadlineDate.Date < model.StartDate.Date) + { + throw new InvalidOperationException("Deadline date must be on or after the start date."); + } + + if (model.SessionLengthMinutes <= 0) + { + throw new InvalidOperationException("Session length minutes must be greater than zero."); + } + } + private static IReadOnlyList NormaliseWritingDays(IEnumerable? selectedDays) { var selected = (selectedDays ?? []) diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index dc1b43d..2f02a8d 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -356,6 +356,48 @@ public sealed class WritingPlanViewModel public DateTime ModifiedDateUtc { get; set; } } +public sealed class WritingPlansManagementViewModel +{ + public IReadOnlyList Plans { get; set; } = []; +} + +public sealed class WritingPlanSettingsEditViewModel +{ + public int WritingPlanID { get; set; } + + [Required, StringLength(200)] + public string PlanName { get; set; } = string.Empty; + + public string ProjectName { get; set; } = string.Empty; + public string BookName { get; set; } = "All Books"; + public string GoalType { get; set; } = string.Empty; + + [DataType(DataType.Date)] + public DateTime StartDate { get; set; } + + [DataType(DataType.Date)] + public DateTime DeadlineDate { get; set; } + + [Range(1, int.MaxValue, ErrorMessage = "Session length minutes must be greater than zero.")] + public int SessionLengthMinutes { get; set; } + + public List SelectedWritingDays { get; set; } = []; + public bool IncludeBlockedScenes { get; set; } + public string RebalanceMode { get; set; } = "Ask"; + public bool IsActive { get; set; } + public IReadOnlyList RebalanceModeOptions { get; set; } = []; + public IReadOnlyList WritingDayOptions { get; set; } = + [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday" + ]; +} + public sealed class WritingScheduleItemViewModel { public int WritingScheduleItemID { get; set; } diff --git a/PlotLine/Views/Writer/EditWritingPlan.cshtml b/PlotLine/Views/Writer/EditWritingPlan.cshtml new file mode 100644 index 0000000..89a3b98 --- /dev/null +++ b/PlotLine/Views/Writer/EditWritingPlan.cshtml @@ -0,0 +1,105 @@ +@model WritingPlanSettingsEditViewModel +@{ + ViewData["Title"] = "Edit Writing Plan"; + var selectedDays = Model.SelectedWritingDays.ToHashSet(StringComparer.OrdinalIgnoreCase); +} + + + +
+
+

Schedule admin

+

Edit Writing Plan

+

Update plan settings without changing the project, book, or goal type.

+
+
+ +
+
+
+ + + +
+
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+ @foreach (var day in Model.WritingDayOptions) + { + + } +
+ +
+ +
+ + +
+
+ +
+ + Cancel +
+
+
+ + diff --git a/PlotLine/Views/Writer/Index.cshtml b/PlotLine/Views/Writer/Index.cshtml index 66d8856..88b3752 100644 --- a/PlotLine/Views/Writer/Index.cshtml +++ b/PlotLine/Views/Writer/Index.cshtml @@ -12,6 +12,7 @@

A practical control room for drafting, revision, polish, and story readiness.

+ Writing Plans Schedule Setup Schedule Preview
diff --git a/PlotLine/Views/Writer/SchedulePreview.cshtml b/PlotLine/Views/Writer/SchedulePreview.cshtml index 6b60f6c..144362b 100644 --- a/PlotLine/Views/Writer/SchedulePreview.cshtml +++ b/PlotLine/Views/Writer/SchedulePreview.cshtml @@ -107,10 +107,7 @@ else
-
- - -
+ @if (Model.SelectedPlanRequiresRebalance) { Preview Rebalance @@ -118,6 +115,28 @@ else
+ + @if (Model.RebalancePreview is not null) {
diff --git a/PlotLine/Views/Writer/WritingPlans.cshtml b/PlotLine/Views/Writer/WritingPlans.cshtml new file mode 100644 index 0000000..3c374fd --- /dev/null +++ b/PlotLine/Views/Writer/WritingPlans.cshtml @@ -0,0 +1,115 @@ +@model WritingPlansManagementViewModel +@{ + ViewData["Title"] = "Writing Plans"; + var message = TempData["WritingPlansMessage"] as string; + var error = TempData["WritingPlansError"] as string; +} + + + +
+
+

Schedule admin

+

Writing Plans

+

Manage writing plans and their generated schedules.

+
+ +
+ +@if (!string.IsNullOrWhiteSpace(message)) +{ +
@message
+} + +@if (!string.IsNullOrWhiteSpace(error)) +{ +
@error
+} + +
+
+
+

Plans

+

Writing Plans

+
+ @Model.Plans.Count plans +
+ + @if (!Model.Plans.Any()) + { +

No writing plans found.

+ } + else + { +
+ + + + + + + + + + + + + + + + + + @foreach (var plan in Model.Plans) + { + + + + + + + + + + + + + + } + +
Plan NameProjectBookGoal TypeStart DateDeadline DateSession LengthActivePlanned TasksProjected Finish Date
@plan.PlanName@plan.ProjectName@(plan.BookTitle ?? "All Books")@plan.GoalType@plan.StartDate.ToString("dd MMM yyyy")@plan.DeadlineDate.ToString("dd MMM yyyy")@plan.SessionLengthMinutes minutes@(plan.IsActive ? "Yes" : "No")@plan.PlannedTaskCount@(plan.ProjectedFinishDate?.ToString("dd MMM yyyy") ?? "-") +
+ View Schedule + Edit Plan + @if (plan.IsActive) + { +
+ + +
+ } + else + { +
+ + +
+ } +
+ + +
+
+ + +
+
+
+
+ } +