Phase 9F: Writing Schedule Rebalancing.
This commit is contained in:
parent
e83425eaac
commit
04dc07e5f5
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user