Phase 9F: Writing Schedule Rebalancing.

This commit is contained in:
Nick Beckley 2026-06-12 22:15:25 +01:00
parent e83425eaac
commit 04dc07e5f5
7 changed files with 316 additions and 0 deletions

View File

@ -29,6 +29,22 @@ public sealed class WriterController(
return View(model);
}
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> MarkScheduleItemComplete(int writingScheduleItemId, int? projectId)

View File

@ -293,6 +293,8 @@ public interface IWritingScheduleRepository
Task DeleteScheduleItemsByPlanAsync(int writingPlanId);
Task<IReadOnlyList<WritingScheduleDashboardItem>> GetActiveScheduleDashboardItemsAsync(int userId);
Task<int> UpdatePlannedScheduleItemStatusForUserAsync(int writingScheduleItemId, int userId, string scheduleStatus, DateTime? completedDateUtc);
Task<IReadOnlyList<WritingPlanSummary>> GetPlansRequiringRebalanceAsync(int userId, DateTime today);
Task UpdatePlannedScheduleItemDatesAsync(int writingPlanId, IReadOnlyList<WritingScheduleItemDateChange> changes);
}
public interface IExportRepository
@ -1219,6 +1221,81 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa
CompletedDateUtc = completedDateUtc
});
}
public async Task<IReadOnlyList<WritingPlanSummary>> GetPlansRequiringRebalanceAsync(int userId, DateTime today)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<WritingPlanSummary>(
"""
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<WritingScheduleItemDateChange> 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

View File

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

View File

@ -280,6 +280,9 @@ public interface IWritingScheduleService
Task<WritingScheduleDashboardViewModel> GetTodayDashboardAsync();
Task<bool> MarkScheduleItemCompleteAsync(int writingScheduleItemId);
Task<bool> SkipScheduleItemAsync(int writingScheduleItemId);
Task<IReadOnlyList<WritingPlanSummary>> GetPlansRequiringRebalanceAsync();
Task<WritingScheduleRebalancePreviewViewModel> PreviewRebalanceAsync(int writingPlanId);
Task<WritingScheduleRebalancePreviewViewModel> 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<IReadOnlyList<WritingPlanSummary>> GetPlansRequiringRebalanceAsync()
{
if (!currentUser.UserId.HasValue)
{
return [];
}
return await writingSchedule.GetPlansRequiringRebalanceAsync(currentUser.UserId.Value, DateTime.Today);
}
public async Task<WritingScheduleRebalancePreviewViewModel> 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<WritingScheduleRebalancePreviewViewModel> 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<WritingPlan> 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<DateTime> GetNextAvailableDates(DateTime startDate, IReadOnlySet<DayOfWeek> availableDays, int count)
{
var dates = new List<DateTime>();
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

View File

@ -325,6 +325,7 @@ public sealed class WriterDashboardViewModel
public sealed class WritingScheduleDashboardViewModel
{
public int ActivePlanCount { get; set; }
public int RebalancePlanCount { get; set; }
public IReadOnlyList<WritingScheduleDashboardItem> TodayTasks { get; set; } = [];
public IReadOnlyList<WritingScheduleDashboardItem> UpcomingTasks { get; set; } = [];
public int TotalPlannedTasks { get; set; }
@ -375,8 +376,12 @@ public sealed class WritingSchedulePreviewViewModel
{
public int? SelectedWritingPlanID { get; set; }
public IReadOnlyList<WritingPlanSummary> Plans { get; set; } = [];
public IReadOnlyList<WritingPlanSummary> PlansRequiringRebalance { get; set; } = [];
public WritingPlanSummary? SelectedPlan { get; set; }
public IReadOnlyList<WritingSchedulePreviewItem> 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<WritingScheduleRebalanceChangeViewModel> 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)]

View File

@ -33,6 +33,14 @@
<div class="alert alert-warning">@error</div>
}
@if (Model.WritingSchedule.RebalancePlanCount > 0)
{
<div class="alert alert-info">
You have @Model.WritingSchedule.RebalancePlanCount writing schedule(s) that may need rebalancing.
<a asp-action="SchedulePreview">Review Schedules</a>
</div>
}
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>

View File

@ -96,6 +96,11 @@ else
@if (Model.SelectedPlan is not null)
{
@if (Model.SelectedPlanRequiresRebalance)
{
<div class="alert alert-warning">Schedule requires attention.</div>
}
<section class="story-bible-section">
<div class="button-row">
<form asp-action="GenerateSchedule" method="post">
@ -106,9 +111,70 @@ else
<input type="hidden" name="writingPlanId" value="@Model.SelectedPlan.WritingPlanID" />
<button class="btn btn-outline-danger" type="submit">Delete Generated Schedule</button>
</form>
@if (Model.SelectedPlanRequiresRebalance)
{
<a class="btn btn-outline-primary" asp-action="PreviewRebalance" asp-route-writingPlanId="@Model.SelectedPlan.WritingPlanID">Preview Rebalance</a>
}
</div>
</section>
@if (Model.RebalancePreview is not null)
{
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">Rebalance</p>
<h2>Rebalance Preview</h2>
</div>
</div>
<div class="story-bible-grid story-bible-grid-compact">
<article class="story-bible-card"><h3>Current Finish Date</h3><strong class="writer-kpi">@(Model.RebalancePreview.OriginalFinishDate?.ToString("dd MMM yyyy") ?? "-")</strong></article>
<article class="story-bible-card"><h3>Proposed Finish Date</h3><strong class="writer-kpi">@(Model.RebalancePreview.ProposedFinishDate?.ToString("dd MMM yyyy") ?? "-")</strong></article>
<article class="story-bible-card"><h3>Tasks Affected</h3><strong class="writer-kpi">@Model.RebalancePreview.TasksAffected</strong></article>
</div>
@if (!Model.RebalancePreview.Changes.Any())
{
<p class="muted">No planned tasks need new dates.</p>
}
else
{
<div class="table-responsive mt-3">
<table class="table align-middle">
<thead>
<tr>
<th>Task Type</th>
<th>Scene</th>
<th>Current Date</th>
<th>Proposed Date</th>
</tr>
</thead>
<tbody>
@foreach (var change in Model.RebalancePreview.Changes)
{
<tr>
<td>@change.TaskType</td>
<td>@change.SceneName</td>
<td>@change.CurrentDate.ToString("dd MMM yyyy")</td>
<td>@change.ProposedDate.ToString("dd MMM yyyy")</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="button-row mt-3">
<form asp-action="ApplyRebalance" method="post">
<input type="hidden" name="writingPlanId" value="@Model.RebalancePreview.WritingPlanID" />
<button class="btn btn-primary" type="submit">Apply Rebalance</button>
</form>
<a class="btn btn-outline-secondary" asp-action="SchedulePreview" asp-route-writingPlanId="@Model.RebalancePreview.WritingPlanID">Cancel</a>
</div>
</section>
}
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>