From 82eb3c85c0bc9871c90142bfe7e704867f3f6f37 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 12 Jun 2026 21:11:05 +0100 Subject: [PATCH] Phase 9C: deterministic Writing Schedule generation engine, with no UI or background/scheduling extras. --- PlotLine/Data/Repositories.cs | 84 +++++++++++++++++++++++++ PlotLine/Models/CoreModels.cs | 12 ++++ PlotLine/Services/CoreServices.cs | 101 ++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index ef7c6f0..2132af5 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -286,6 +286,8 @@ public interface IWritingScheduleRepository Task UpdateScheduleItemAsync(WritingScheduleItem item); Task DeleteScheduleItemAsync(int writingScheduleItemId); Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc); + Task> GetSchedulingScenesAsync(int projectId, int? bookId, bool includeBlockedScenes); + Task ReplaceScheduleItemsAsync(int writingPlanId, IReadOnlyList items); } public interface IExportRepository @@ -1004,6 +1006,88 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa new { WritingScheduleItemID = writingScheduleItemId, ScheduleStatus = scheduleStatus, CompletedDateUtc = completedDateUtc }, commandType: CommandType.StoredProcedure); } + + public async Task> GetSchedulingScenesAsync(int projectId, int? bookId, bool includeBlockedScenes) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + SELECT s.SceneID, + rs.StatusName AS RevisionStatusName, + sw.EstimatedWordCount, + sw.Priority, + COALESCE(sw.IsBlocked, 0) AS IsBlocked, + b.SortOrder AS BookSortOrder, + c.SortOrder AS ChapterSortOrder, + s.SortOrder AS SceneSortOrder + FROM dbo.Projects p + INNER JOIN dbo.Books b ON b.ProjectID = p.ProjectID AND b.IsArchived = 0 + INNER JOIN dbo.Chapters c ON c.BookID = b.BookID AND c.IsArchived = 0 + INNER JOIN dbo.Scenes s ON s.ChapterID = c.ChapterID AND s.IsArchived = 0 + INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID + LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID + WHERE p.ProjectID = @ProjectID + AND p.IsArchived = 0 + AND (@BookID IS NULL OR b.BookID = @BookID) + AND rs.StatusName IN (N'Outlined', N'Needs Work', N'Revised') + AND (@IncludeBlockedScenes = 1 OR COALESCE(sw.IsBlocked, 0) = 0) + ORDER BY COALESCE(sw.Priority, 0) DESC, b.SortOrder, c.SortOrder, s.SortOrder, s.SceneID; + """, + new { ProjectID = projectId, BookID = bookId, IncludeBlockedScenes = includeBlockedScenes }); + return rows.ToList(); + } + + public async Task ReplaceScheduleItemsAsync(int writingPlanId, IReadOnlyList items) + { + using var connection = connectionFactory.CreateConnection(); + connection.Open(); + using var transaction = connection.BeginTransaction(); + + try + { + await connection.ExecuteAsync( + "DELETE FROM dbo.WritingScheduleItems WHERE WritingPlanID = @WritingPlanID;", + new { WritingPlanID = writingPlanId }, + transaction); + + foreach (var item in items) + { + await connection.ExecuteAsync( + """ + INSERT dbo.WritingScheduleItems + ( + WritingPlanID, SceneID, ScheduledDate, TaskType, EstimatedMinutes, TargetWords, + ScheduleStatus, CompletedDateUtc, Notes + ) + VALUES + ( + @WritingPlanID, @SceneID, @ScheduledDate, @TaskType, @EstimatedMinutes, @TargetWords, + @ScheduleStatus, @CompletedDateUtc, @Notes + ); + """, + new + { + item.WritingPlanID, + item.SceneID, + item.ScheduledDate, + item.TaskType, + item.EstimatedMinutes, + item.TargetWords, + item.ScheduleStatus, + item.CompletedDateUtc, + item.Notes + }, + 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 1488c1c..cc74d9e 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -1607,6 +1607,18 @@ public sealed class WritingScheduleItem public DateTime ModifiedDateUtc { get; set; } } +public sealed class WritingScheduleScene +{ + public int SceneID { get; set; } + public string RevisionStatusName { get; set; } = string.Empty; + public int? EstimatedWordCount { get; set; } + public int? Priority { get; set; } + public bool IsBlocked { get; set; } + public int BookSortOrder { get; set; } + public int ChapterSortOrder { get; set; } + public int SceneSortOrder { get; set; } +} + public sealed class ChapterWorkflowScene { public int SceneID { get; set; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index e021b40..e2e4c4d 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Net; using System.Text; +using System.Text.Json; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.Extensions.Options; using PlotLine.Data; @@ -270,6 +271,7 @@ public interface IWritingScheduleService Task UpdateScheduleItemAsync(WritingScheduleItemViewModel model); Task DeleteScheduleItemAsync(int writingScheduleItemId); Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc); + Task> GenerateSchedule(int writingPlanId); } public interface IStorageService @@ -3337,6 +3339,42 @@ public sealed class WritingScheduleService(IWritingScheduleRepository writingSch public Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc) => writingSchedule.UpdateScheduleItemStatusAsync(writingScheduleItemId, scheduleStatus, completedDateUtc); + public async Task> GenerateSchedule(int writingPlanId) + { + var plan = await writingSchedule.GetWritingPlanByIDAsync(writingPlanId) + ?? throw new InvalidOperationException("Writing plan not found."); + var planViewModel = ToViewModel(plan); + ValidatePlan(planViewModel); + + var availableDays = ParseAvailableDays(plan.AvailableDaysJson); + if (availableDays.Count == 0) + { + throw new InvalidOperationException("Available days must include at least one valid day."); + } + + var scenes = await writingSchedule.GetSchedulingScenesAsync(plan.ProjectID, plan.BookID, plan.IncludeBlockedScenes); + if (scenes.Count == 0) + { + await writingSchedule.ReplaceScheduleItemsAsync(plan.WritingPlanID, []); + 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(); + + await writingSchedule.ReplaceScheduleItemsAsync(plan.WritingPlanID, items); + + var savedItems = await writingSchedule.GetScheduleItemsByPlanAsync(plan.WritingPlanID); + return savedItems.Select(ToViewModel).ToList(); + } + private static void ValidatePlan(WritingPlanViewModel model) { if (model.DeadlineDate.Date < model.StartDate.Date) @@ -3363,6 +3401,69 @@ public sealed class WritingScheduleService(IWritingScheduleRepository writingSch } } + private static IReadOnlySet ParseAvailableDays(string? availableDaysJson) + { + if (string.IsNullOrWhiteSpace(availableDaysJson)) + { + return new HashSet(); + } + + try + { + var dayNames = JsonSerializer.Deserialize>(availableDaysJson) ?? []; + return dayNames + .Select(day => Enum.TryParse(day, ignoreCase: true, out var parsedDay) ? parsedDay : (DayOfWeek?)null) + .Where(day => day.HasValue) + .Select(day => day!.Value) + .ToHashSet(); + } + catch (JsonException) + { + return new HashSet(); + } + } + + private static IReadOnlyList GetAvailableDates(DateTime startDate, DateTime deadlineDate, IReadOnlySet availableDays) + { + var dates = new List(); + for (var date = startDate.Date; date <= deadlineDate.Date; 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 + { + "Outlined" => ("Draft", EstimateDraftMinutes(scene.EstimatedWordCount), scene.EstimatedWordCount > 0 ? scene.EstimatedWordCount : null), + "Needs Work" => ("Revise", 45, null), + "Revised" => ("Polish", 30, null), + _ => 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" + }; + } + + private static int EstimateDraftMinutes(int? estimatedWordCount) + => estimatedWordCount is > 0 + ? (int)Math.Ceiling(estimatedWordCount.GetValueOrDefault() / 750.0 * 60) + : 90; + private static WritingPlanViewModel ToViewModel(WritingPlan plan) => new() { WritingPlanID = plan.WritingPlanID,