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 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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user