Phase 9C: deterministic Writing Schedule generation engine, with no UI or background/scheduling extras.

This commit is contained in:
Nick Beckley 2026-06-12 21:11:05 +01:00
parent e66b43064a
commit 82eb3c85c0
3 changed files with 197 additions and 0 deletions

View File

@ -286,6 +286,8 @@ public interface IWritingScheduleRepository
Task UpdateScheduleItemAsync(WritingScheduleItem item);
Task DeleteScheduleItemAsync(int writingScheduleItemId);
Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc);
Task<IReadOnlyList<WritingScheduleScene>> GetSchedulingScenesAsync(int projectId, int? bookId, bool includeBlockedScenes);
Task ReplaceScheduleItemsAsync(int writingPlanId, IReadOnlyList<WritingScheduleItem> 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<IReadOnlyList<WritingScheduleScene>> GetSchedulingScenesAsync(int projectId, int? bookId, bool includeBlockedScenes)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<WritingScheduleScene>(
"""
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<WritingScheduleItem> 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

View File

@ -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; }

View File

@ -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<IReadOnlyList<WritingScheduleItemViewModel>> 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<IReadOnlyList<WritingScheduleItemViewModel>> 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<DayOfWeek> ParseAvailableDays(string? availableDaysJson)
{
if (string.IsNullOrWhiteSpace(availableDaysJson))
{
return new HashSet<DayOfWeek>();
}
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();
}
catch (JsonException)
{
return new HashSet<DayOfWeek>();
}
}
private static IReadOnlyList<DateTime> GetAvailableDates(DateTime startDate, DateTime deadlineDate, IReadOnlySet<DayOfWeek> availableDays)
{
var dates = new List<DateTime>();
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,