From 04dc07e5f528baaa378f918b5635c10ef685a812 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 12 Jun 2026 22:15:25 +0100 Subject: [PATCH] Phase 9F: Writing Schedule Rebalancing. --- PlotLine/Controllers/WriterController.cs | 33 ++++++ PlotLine/Data/Repositories.cs | 77 ++++++++++++++ PlotLine/Models/CoreModels.cs | 6 ++ PlotLine/Services/CoreServices.cs | 103 +++++++++++++++++++ PlotLine/ViewModels/CoreViewModels.cs | 23 +++++ PlotLine/Views/Writer/Index.cshtml | 8 ++ PlotLine/Views/Writer/SchedulePreview.cshtml | 66 ++++++++++++ 7 files changed, 316 insertions(+) diff --git a/PlotLine/Controllers/WriterController.cs b/PlotLine/Controllers/WriterController.cs index 4f83409..a022875 100644 --- a/PlotLine/Controllers/WriterController.cs +++ b/PlotLine/Controllers/WriterController.cs @@ -29,6 +29,22 @@ public sealed class WriterController( return View(model); } + public async Task PreviewRebalance(int writingPlanId) + { + var model = await writingSchedule.GetSchedulePreviewAsync(writingPlanId); + try + { + model.RebalancePreview = await writingSchedule.PreviewRebalanceAsync(writingPlanId); + } + catch (InvalidOperationException ex) + { + TempData["SchedulePreviewError"] = ex.Message; + return RedirectToAction(nameof(SchedulePreview), new { writingPlanId }); + } + + return View(nameof(SchedulePreview), model); + } + public async Task ScheduleSetup(int? projectId) { var model = await writingSchedule.GetScheduleSetupAsync(projectId); @@ -109,6 +125,23 @@ public sealed class WriterController( return RedirectToAction(nameof(SchedulePreview), new { writingPlanId }); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task ApplyRebalance(int writingPlanId) + { + try + { + await writingSchedule.ApplyRebalanceAsync(writingPlanId); + TempData["SchedulePreviewMessage"] = "Schedule rebalanced successfully."; + } + catch (InvalidOperationException ex) + { + TempData["SchedulePreviewError"] = ex.Message; + } + + return RedirectToAction(nameof(SchedulePreview), new { writingPlanId }); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task MarkScheduleItemComplete(int writingScheduleItemId, int? projectId) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index a853bb5..efc6faf 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -293,6 +293,8 @@ public interface IWritingScheduleRepository Task DeleteScheduleItemsByPlanAsync(int writingPlanId); Task> GetActiveScheduleDashboardItemsAsync(int userId); Task UpdatePlannedScheduleItemStatusForUserAsync(int writingScheduleItemId, int userId, string scheduleStatus, DateTime? completedDateUtc); + Task> GetPlansRequiringRebalanceAsync(int userId, DateTime today); + Task UpdatePlannedScheduleItemDatesAsync(int writingPlanId, IReadOnlyList changes); } public interface IExportRepository @@ -1219,6 +1221,81 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa CompletedDateUtc = completedDateUtc }); } + + public async Task> GetPlansRequiringRebalanceAsync(int userId, DateTime today) + { + 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.DeadlineDate, + wp.IsActive + FROM dbo.WritingPlans wp + INNER JOIN dbo.Projects p ON p.ProjectID = wp.ProjectID + LEFT JOIN dbo.Books b ON b.BookID = wp.BookID + WHERE wp.UserID = @UserID + AND wp.IsActive = 1 + AND EXISTS + ( + SELECT 1 + FROM dbo.WritingScheduleItems wsi + WHERE wsi.WritingPlanID = wp.WritingPlanID + AND wsi.ScheduleStatus = N'Planned' + AND wsi.ScheduledDate < @Today + ) + ORDER BY wp.DeadlineDate, wp.PlanName, wp.WritingPlanID; + """, + new { UserID = userId, Today = today.Date }); + return rows.ToList(); + } + + public async Task UpdatePlannedScheduleItemDatesAsync(int writingPlanId, IReadOnlyList changes) + { + if (changes.Count == 0) + { + return; + } + + using var connection = connectionFactory.CreateConnection(); + connection.Open(); + using var transaction = connection.BeginTransaction(); + + try + { + foreach (var change in changes) + { + await connection.ExecuteAsync( + """ + UPDATE dbo.WritingScheduleItems + SET ScheduledDate = @ScheduledDate, + ModifiedDateUtc = SYSUTCDATETIME() + WHERE WritingPlanID = @WritingPlanID + AND WritingScheduleItemID = @WritingScheduleItemID + AND ScheduleStatus = N'Planned'; + """, + new + { + WritingPlanID = writingPlanId, + change.WritingScheduleItemID, + ScheduledDate = change.ScheduledDate.Date + }, + transaction); + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } } public sealed class ExportRepository(ISqlConnectionFactory connectionFactory) : IExportRepository diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 6446fc8..8b4f3f3 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -1673,6 +1673,12 @@ public sealed class WritingScheduleDashboardItem public bool IsBlocked { get; set; } } +public sealed class WritingScheduleItemDateChange +{ + public int WritingScheduleItemID { get; set; } + public DateTime ScheduledDate { get; set; } +} + public sealed class ChapterWorkflowScene { public int SceneID { get; set; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index c13453d..bd0eb78 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -280,6 +280,9 @@ public interface IWritingScheduleService Task GetTodayDashboardAsync(); Task MarkScheduleItemCompleteAsync(int writingScheduleItemId); Task SkipScheduleItemAsync(int writingScheduleItemId); + Task> GetPlansRequiringRebalanceAsync(); + Task PreviewRebalanceAsync(int writingPlanId); + Task ApplyRebalanceAsync(int writingPlanId); } public interface IStorageService @@ -3395,6 +3398,7 @@ public sealed class WritingScheduleService( } var plans = await writingSchedule.GetWritingPlanSummariesByUserAsync(currentUser.UserId.Value); + var plansRequiringRebalance = await writingSchedule.GetPlansRequiringRebalanceAsync(currentUser.UserId.Value, DateTime.Today); var selectedPlan = writingPlanId.HasValue ? plans.FirstOrDefault(x => x.WritingPlanID == writingPlanId.Value) : plans.FirstOrDefault(); @@ -3406,6 +3410,7 @@ public sealed class WritingScheduleService( { SelectedWritingPlanID = selectedPlan?.WritingPlanID, Plans = plans, + PlansRequiringRebalance = plansRequiringRebalance, SelectedPlan = selectedPlan, Items = items }; @@ -3540,6 +3545,7 @@ public sealed class WritingScheduleService( var plans = await writingSchedule.GetWritingPlanSummariesByUserAsync(currentUser.UserId.Value); var activePlanCount = plans.Count(x => x.IsActive); + var plansRequiringRebalance = await writingSchedule.GetPlansRequiringRebalanceAsync(currentUser.UserId.Value, DateTime.Today); var items = await writingSchedule.GetActiveScheduleDashboardItemsAsync(currentUser.UserId.Value); var today = DateTime.Today; var plannedItems = items @@ -3549,6 +3555,7 @@ public sealed class WritingScheduleService( return new WritingScheduleDashboardViewModel { ActivePlanCount = activePlanCount, + RebalancePlanCount = plansRequiringRebalance.Count, TodayTasks = plannedItems .Where(x => x.ScheduledDate.Date == today) .OrderByDescending(x => x.Priority ?? 0) @@ -3601,6 +3608,88 @@ public sealed class WritingScheduleService( return affectedRows > 0; } + public async Task> GetPlansRequiringRebalanceAsync() + { + if (!currentUser.UserId.HasValue) + { + return []; + } + + return await writingSchedule.GetPlansRequiringRebalanceAsync(currentUser.UserId.Value, DateTime.Today); + } + + public async Task PreviewRebalanceAsync(int writingPlanId) + { + var plan = await GetActivePlanForCurrentUserAsync(writingPlanId); + var plannedItems = (await writingSchedule.GetSchedulePreviewItemsAsync(plan.WritingPlanID)) + .Where(x => string.Equals(x.ScheduleStatus, "Planned", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (plannedItems.Count == 0) + { + return new WritingScheduleRebalancePreviewViewModel { WritingPlanID = plan.WritingPlanID }; + } + + var availableDays = ParseAvailableDays(plan.AvailableDaysJson); + if (availableDays.Count == 0) + { + throw new InvalidOperationException("Available days must include at least one valid day."); + } + + var proposedDates = GetNextAvailableDates(DateTime.Today, availableDays, plannedItems.Count); + var changes = plannedItems + .Select((item, index) => new { Item = item, ProposedDate = proposedDates[index] }) + .Where(x => x.Item.ScheduledDate.Date != x.ProposedDate.Date) + .Select(x => new WritingScheduleRebalanceChangeViewModel + { + WritingScheduleItemID = x.Item.WritingScheduleItemID, + TaskType = x.Item.TaskType, + SceneName = $"Scene {x.Item.SceneNumber}: {x.Item.SceneTitle}", + CurrentDate = x.Item.ScheduledDate.Date, + ProposedDate = x.ProposedDate.Date + }) + .ToList(); + + return new WritingScheduleRebalancePreviewViewModel + { + WritingPlanID = plan.WritingPlanID, + OriginalFinishDate = plannedItems.Max(x => x.ScheduledDate.Date), + ProposedFinishDate = proposedDates.Max(), + Changes = changes + }; + } + + public async Task ApplyRebalanceAsync(int writingPlanId) + { + var preview = await PreviewRebalanceAsync(writingPlanId); + await writingSchedule.UpdatePlannedScheduleItemDatesAsync( + preview.WritingPlanID, + preview.Changes + .Select(x => new WritingScheduleItemDateChange + { + WritingScheduleItemID = x.WritingScheduleItemID, + ScheduledDate = x.ProposedDate + }) + .ToList()); + return preview; + } + + private async Task GetActivePlanForCurrentUserAsync(int writingPlanId) + { + if (!currentUser.UserId.HasValue) + { + throw new InvalidOperationException("Sign in before rebalancing a writing schedule."); + } + + var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId); + if (plan is null || plan.UserID != currentUser.UserId.Value || !plan.IsActive) + { + throw new InvalidOperationException("Writing plan not found or is not active."); + } + + return plan; + } + private static void ValidateSetup(WritingScheduleSetupViewModel model) { if (string.IsNullOrWhiteSpace(model.PlanName)) @@ -3702,6 +3791,20 @@ public sealed class WritingScheduleService( return dates; } + private static IReadOnlyList GetNextAvailableDates(DateTime startDate, IReadOnlySet availableDays, int count) + { + var dates = new List(); + for (var date = startDate.Date; dates.Count < count; date = date.AddDays(1)) + { + if (availableDays.Contains(date.DayOfWeek)) + { + dates.Add(date); + } + } + + return dates; + } + private static WritingScheduleItem CreateGeneratedItem(int writingPlanId, WritingScheduleScene scene, DateTime scheduledDate) { var (taskType, estimatedMinutes, targetWords) = scene.RevisionStatusName switch diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index f3ba84f..dc1b43d 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -325,6 +325,7 @@ public sealed class WriterDashboardViewModel public sealed class WritingScheduleDashboardViewModel { public int ActivePlanCount { get; set; } + public int RebalancePlanCount { get; set; } public IReadOnlyList TodayTasks { get; set; } = []; public IReadOnlyList UpcomingTasks { get; set; } = []; public int TotalPlannedTasks { get; set; } @@ -375,8 +376,12 @@ public sealed class WritingSchedulePreviewViewModel { public int? SelectedWritingPlanID { get; set; } public IReadOnlyList Plans { get; set; } = []; + public IReadOnlyList PlansRequiringRebalance { get; set; } = []; public WritingPlanSummary? SelectedPlan { get; set; } public IReadOnlyList Items { get; set; } = []; + public WritingScheduleRebalancePreviewViewModel? RebalancePreview { get; set; } + public bool SelectedPlanRequiresRebalance => SelectedWritingPlanID.HasValue + && PlansRequiringRebalance.Any(x => x.WritingPlanID == SelectedWritingPlanID.Value); public int TotalTasks => Items.Count; public int DraftTasks => Items.Count(x => x.TaskType == "Draft"); public int RevisionTasks => Items.Count(x => x.TaskType == "Revise"); @@ -385,6 +390,24 @@ public sealed class WritingSchedulePreviewViewModel public DateTime? ProjectedFinishDate => Items.Count == 0 ? null : Items.Max(x => x.ScheduledDate); } +public sealed class WritingScheduleRebalancePreviewViewModel +{ + public int WritingPlanID { get; set; } + public DateTime? OriginalFinishDate { get; set; } + public DateTime? ProposedFinishDate { get; set; } + public IReadOnlyList Changes { get; set; } = []; + public int TasksAffected => Changes.Count; +} + +public sealed class WritingScheduleRebalanceChangeViewModel +{ + public int WritingScheduleItemID { get; set; } + public string TaskType { get; set; } = string.Empty; + public string SceneName { get; set; } = string.Empty; + public DateTime CurrentDate { get; set; } + public DateTime ProposedDate { get; set; } +} + public sealed class WritingScheduleSetupViewModel { [Required, StringLength(200)] diff --git a/PlotLine/Views/Writer/Index.cshtml b/PlotLine/Views/Writer/Index.cshtml index 45cf694..66d8856 100644 --- a/PlotLine/Views/Writer/Index.cshtml +++ b/PlotLine/Views/Writer/Index.cshtml @@ -33,6 +33,14 @@
@error
} +@if (Model.WritingSchedule.RebalancePlanCount > 0) +{ +
+ You have @Model.WritingSchedule.RebalancePlanCount writing schedule(s) that may need rebalancing. + Review Schedules +
+} +
diff --git a/PlotLine/Views/Writer/SchedulePreview.cshtml b/PlotLine/Views/Writer/SchedulePreview.cshtml index 6d32137..6b60f6c 100644 --- a/PlotLine/Views/Writer/SchedulePreview.cshtml +++ b/PlotLine/Views/Writer/SchedulePreview.cshtml @@ -96,6 +96,11 @@ else @if (Model.SelectedPlan is not null) { + @if (Model.SelectedPlanRequiresRebalance) + { +
Schedule requires attention.
+ } +
@@ -106,9 +111,70 @@ else
+ @if (Model.SelectedPlanRequiresRebalance) + { + Preview Rebalance + }
+ @if (Model.RebalancePreview is not null) + { +
+
+
+

Rebalance

+

Rebalance Preview

+
+
+ +
+

Current Finish Date

@(Model.RebalancePreview.OriginalFinishDate?.ToString("dd MMM yyyy") ?? "-")
+

Proposed Finish Date

@(Model.RebalancePreview.ProposedFinishDate?.ToString("dd MMM yyyy") ?? "-")
+

Tasks Affected

@Model.RebalancePreview.TasksAffected
+
+ + @if (!Model.RebalancePreview.Changes.Any()) + { +

No planned tasks need new dates.

+ } + else + { +
+ + + + + + + + + + + @foreach (var change in Model.RebalancePreview.Changes) + { + + + + + + + } + +
Task TypeSceneCurrent DateProposed Date
@change.TaskType@change.SceneName@change.CurrentDate.ToString("dd MMM yyyy")@change.ProposedDate.ToString("dd MMM yyyy")
+
+ } + +
+
+ + +
+ Cancel +
+
+ } +