Phase 9C: deterministic Writing Schedule generation engine, with no UI or background/scheduling extras.
This commit is contained in:
parent
e66b43064a
commit
82eb3c85c0
@ -286,6 +286,8 @@ public interface IWritingScheduleRepository
|
|||||||
Task UpdateScheduleItemAsync(WritingScheduleItem item);
|
Task UpdateScheduleItemAsync(WritingScheduleItem item);
|
||||||
Task DeleteScheduleItemAsync(int writingScheduleItemId);
|
Task DeleteScheduleItemAsync(int writingScheduleItemId);
|
||||||
Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc);
|
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
|
public interface IExportRepository
|
||||||
@ -1004,6 +1006,88 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa
|
|||||||
new { WritingScheduleItemID = writingScheduleItemId, ScheduleStatus = scheduleStatus, CompletedDateUtc = completedDateUtc },
|
new { WritingScheduleItemID = writingScheduleItemId, ScheduleStatus = scheduleStatus, CompletedDateUtc = completedDateUtc },
|
||||||
commandType: CommandType.StoredProcedure);
|
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
|
public sealed class ExportRepository(ISqlConnectionFactory connectionFactory) : IExportRepository
|
||||||
|
|||||||
@ -1607,6 +1607,18 @@ public sealed class WritingScheduleItem
|
|||||||
public DateTime ModifiedDateUtc { get; set; }
|
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 sealed class ChapterWorkflowScene
|
||||||
{
|
{
|
||||||
public int SceneID { get; set; }
|
public int SceneID { get; set; }
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PlotLine.Data;
|
using PlotLine.Data;
|
||||||
@ -270,6 +271,7 @@ public interface IWritingScheduleService
|
|||||||
Task UpdateScheduleItemAsync(WritingScheduleItemViewModel model);
|
Task UpdateScheduleItemAsync(WritingScheduleItemViewModel model);
|
||||||
Task DeleteScheduleItemAsync(int writingScheduleItemId);
|
Task DeleteScheduleItemAsync(int writingScheduleItemId);
|
||||||
Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc);
|
Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc);
|
||||||
|
Task<IReadOnlyList<WritingScheduleItemViewModel>> GenerateSchedule(int writingPlanId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IStorageService
|
public interface IStorageService
|
||||||
@ -3337,6 +3339,42 @@ public sealed class WritingScheduleService(IWritingScheduleRepository writingSch
|
|||||||
public Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc) =>
|
public Task UpdateScheduleItemStatusAsync(int writingScheduleItemId, string scheduleStatus, DateTime? completedDateUtc) =>
|
||||||
writingSchedule.UpdateScheduleItemStatusAsync(writingScheduleItemId, scheduleStatus, 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)
|
private static void ValidatePlan(WritingPlanViewModel model)
|
||||||
{
|
{
|
||||||
if (model.DeadlineDate.Date < model.StartDate.Date)
|
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()
|
private static WritingPlanViewModel ToViewModel(WritingPlan plan) => new()
|
||||||
{
|
{
|
||||||
WritingPlanID = plan.WritingPlanID,
|
WritingPlanID = plan.WritingPlanID,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user