Phase 9D: Writing Schedule Setup Wizard

This commit is contained in:
Nick Beckley 2026-06-12 21:41:05 +01:00
parent a56b900863
commit 218cc87327
5 changed files with 363 additions and 0 deletions

View File

@ -28,6 +28,60 @@ public sealed class WriterController(
return View(model);
}
public async Task<IActionResult> ScheduleSetup(int? projectId)
{
var model = await writingSchedule.GetScheduleSetupAsync(projectId);
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ScheduleSetup(WritingScheduleSetupViewModel model)
{
if (!Request.Form.ContainsKey(nameof(model.SelectedWritingDays)))
{
model.SelectedWritingDays = [];
}
if (!model.SelectedWritingDays.Any())
{
ModelState.AddModelError(string.Empty, "Select at least one writing day.");
ModelState.AddModelError(nameof(model.SelectedWritingDays), "Select at least one writing day.");
}
if (model.DeadlineDate.Date < model.StartDate.Date)
{
ModelState.AddModelError(nameof(model.DeadlineDate), "Deadline date must be on or after the start date.");
}
if (!ModelState.IsValid)
{
await writingSchedule.PopulateScheduleSetupOptionsAsync(model);
return View(model);
}
try
{
var result = await writingSchedule.CreateScheduleFromSetupAsync(model);
if (string.IsNullOrWhiteSpace(result.ValidationMessage))
{
TempData["SchedulePreviewMessage"] = $"Writing plan created. {result.GeneratedItemCount} item{(result.GeneratedItemCount == 1 ? "" : "s")} generated.";
}
else
{
TempData["SchedulePreviewError"] = result.ValidationMessage;
}
return RedirectToAction(nameof(SchedulePreview), new { writingPlanId = result.WritingPlanID });
}
catch (InvalidOperationException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
await writingSchedule.PopulateScheduleSetupOptionsAsync(model);
return View(model);
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> GenerateSchedule(int writingPlanId)

View File

@ -274,6 +274,9 @@ public interface IWritingScheduleService
Task<IReadOnlyList<WritingScheduleItemViewModel>> GenerateSchedule(int writingPlanId);
Task<WritingSchedulePreviewViewModel> GetSchedulePreviewAsync(int? writingPlanId);
Task DeleteScheduleAsync(int writingPlanId);
Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId);
Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model);
Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model);
}
public interface IStorageService
@ -3281,6 +3284,8 @@ public sealed class WriterWorkspaceService(
public sealed class WritingScheduleService(
IWritingScheduleRepository writingSchedule,
IProjectRepository projects,
IBookRepository books,
ICurrentUserService currentUser) : IWritingScheduleService
{
public async Task<IReadOnlyList<WritingPlanViewModel>> GetWritingPlansByUserAsync(int userId)
@ -3417,6 +3422,151 @@ public sealed class WritingScheduleService(
}
}
public async Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId)
{
var projectRows = currentUser.UserId.HasValue
? await projects.ListForUserAsync(currentUser.UserId.Value)
: [];
var selectedProjectId = projectId.HasValue && projectRows.Any(x => x.ProjectID == projectId.Value)
? projectId
: projectRows.FirstOrDefault()?.ProjectID;
var model = new WritingScheduleSetupViewModel
{
ProjectID = selectedProjectId,
StartDate = DateTime.Today,
DeadlineDate = DateTime.Today.AddMonths(1)
};
await PopulateScheduleSetupOptionsAsync(model);
return model;
}
public async Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model)
{
var projectRows = currentUser.UserId.HasValue
? await projects.ListForUserAsync(currentUser.UserId.Value)
: [];
var selectedProject = model.ProjectID.HasValue
? projectRows.FirstOrDefault(x => x.ProjectID == model.ProjectID.Value)
: null;
var bookRows = selectedProject is null
? []
: await books.ListByProjectAsync(selectedProject.ProjectID);
model.ProjectOptions = projectRows
.Select(x => new SelectListItem(x.ProjectName, x.ProjectID.ToString(), x.ProjectID == model.ProjectID))
.ToList();
model.BookOptions = bookRows
.Select(x => new SelectListItem(x.BookTitle, x.BookID.ToString(), x.BookID == model.BookID))
.ToList();
model.GoalTypeOptions = ScheduleOptionList(["FirstDraft", "Revision", "BetaReady", "Custom"], model.GoalType);
model.RebalanceModeOptions = ScheduleOptionList(["Ask", "Automatic", "Never"], model.RebalanceMode);
}
public async Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model)
{
if (!currentUser.UserId.HasValue)
{
throw new InvalidOperationException("Sign in before creating a writing schedule.");
}
ValidateSetup(model);
var writingDays = NormaliseWritingDays(model.SelectedWritingDays);
if (writingDays.Count == 0)
{
throw new InvalidOperationException("Select at least one writing day.");
}
var projectRows = await projects.ListForUserAsync(currentUser.UserId.Value);
if (!model.ProjectID.HasValue || !projectRows.Any(x => x.ProjectID == model.ProjectID.Value))
{
throw new InvalidOperationException("Choose a valid project.");
}
if (model.BookID.HasValue)
{
var bookRows = await books.ListByProjectAsync(model.ProjectID.Value);
if (!bookRows.Any(x => x.BookID == model.BookID.Value))
{
throw new InvalidOperationException("Choose a valid book.");
}
}
var planId = await CreateWritingPlanAsync(new WritingPlanViewModel
{
UserID = currentUser.UserId.Value,
ProjectID = model.ProjectID!.Value,
BookID = model.BookID,
PlanName = model.PlanName.Trim(),
GoalType = model.GoalType,
StartDate = model.StartDate.Date,
DeadlineDate = model.DeadlineDate.Date,
AvailableDaysJson = JsonSerializer.Serialize(writingDays),
SessionLengthMinutes = model.SessionLengthMinutes,
IncludeBlockedScenes = model.IncludeBlockedScenes,
RebalanceMode = model.RebalanceMode,
IsActive = true
});
try
{
var generatedItems = await GenerateSchedule(planId);
return new WritingScheduleSetupResult
{
WritingPlanID = planId,
GeneratedItemCount = generatedItems.Count
};
}
catch (InvalidOperationException ex)
{
return new WritingScheduleSetupResult
{
WritingPlanID = planId,
ValidationMessage = ex.Message
};
}
}
private static void ValidateSetup(WritingScheduleSetupViewModel model)
{
if (string.IsNullOrWhiteSpace(model.PlanName))
{
throw new InvalidOperationException("Plan name is required.");
}
if (!model.ProjectID.HasValue)
{
throw new InvalidOperationException("Choose a project.");
}
if (model.DeadlineDate.Date < model.StartDate.Date)
{
throw new InvalidOperationException("Deadline date must be on or after the start date.");
}
if (model.SessionLengthMinutes <= 0)
{
throw new InvalidOperationException("Session length minutes must be greater than zero.");
}
}
private static IReadOnlyList<string> NormaliseWritingDays(IEnumerable<string>? selectedDays)
{
var selected = (selectedDays ?? [])
.Where(day => !string.IsNullOrWhiteSpace(day))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return
[
.. new[] { "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" }
.Where(selected.Contains)
];
}
private static IReadOnlyList<SelectListItem> ScheduleOptionList(IEnumerable<string> values, string selectedValue)
=> values.Select(value => new SelectListItem(value, value, string.Equals(value, selectedValue, StringComparison.OrdinalIgnoreCase))).ToList();
private static void ValidatePlan(WritingPlanViewModel model)
{
if (model.DeadlineDate.Date < model.StartDate.Date)

View File

@ -370,6 +370,58 @@ public sealed class WritingSchedulePreviewViewModel
public DateTime? ProjectedFinishDate => Items.Count == 0 ? null : Items.Max(x => x.ScheduledDate);
}
public sealed class WritingScheduleSetupViewModel
{
[Required, StringLength(200)]
public string PlanName { get; set; } = "Writing Schedule";
[Required]
public int? ProjectID { get; set; }
public int? BookID { get; set; }
[Required]
public string GoalType { get; set; } = "FirstDraft";
[DataType(DataType.Date)]
public DateTime StartDate { get; set; } = DateTime.Today;
[DataType(DataType.Date)]
public DateTime DeadlineDate { get; set; } = DateTime.Today.AddMonths(1);
public List<string> SelectedWritingDays { get; set; } = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"];
[Range(1, int.MaxValue, ErrorMessage = "Session length minutes must be greater than zero.")]
public int SessionLengthMinutes { get; set; } = 60;
public bool IncludeBlockedScenes { get; set; }
[Required]
public string RebalanceMode { get; set; } = "Ask";
public IReadOnlyList<SelectListItem> ProjectOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> GoalTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> RebalanceModeOptions { get; set; } = [];
public IReadOnlyList<string> WritingDayOptions { get; set; } =
[
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
];
}
public sealed class WritingScheduleSetupResult
{
public int WritingPlanID { get; init; }
public int GeneratedItemCount { get; init; }
public string? ValidationMessage { get; init; }
}
public sealed class CharacterArcViewModel
{
public Project Project { get; set; } = new();

View File

@ -10,6 +10,7 @@
<p class="lead-text">A practical control room for drafting, revision, polish, and story readiness.</p>
</div>
<div class="button-row">
<a class="btn btn-outline-primary" asp-action="ScheduleSetup" asp-route-projectId="@Model.ProjectID">Schedule Setup</a>
<a class="btn btn-outline-secondary" asp-action="SchedulePreview">Schedule Preview</a>
<form asp-action="Index" method="get" class="story-bible-search">
<select class="form-select" name="projectId" asp-items="Model.ProjectOptions">

View File

@ -0,0 +1,106 @@
@model WritingScheduleSetupViewModel
@{
ViewData["Title"] = "Schedule Setup";
var selectedDays = Model.SelectedWritingDays.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Writer" asp-action="Index">Writer Workspace</a>
<span>Schedule Setup</span>
</nav>
<div class="page-heading compact">
<div>
<p class="eyebrow">Writing schedule</p>
<h1>Schedule Setup</h1>
<p class="lead-text">Create a writing plan and generate its first schedule.</p>
</div>
</div>
<section class="story-bible-section">
<form asp-action="ScheduleSetup" method="post" class="story-bible-card chapter-goal-form">
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3">
<div class="col-md-6">
<label asp-for="PlanName" class="form-label">Plan Name</label>
<input asp-for="PlanName" class="form-control" />
<span asp-validation-for="PlanName" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="GoalType" class="form-label">Goal Type</label>
<select asp-for="GoalType" asp-items="Model.GoalTypeOptions" class="form-select"></select>
<span asp-validation-for="GoalType" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="ProjectID" class="form-label">Project</label>
<select asp-for="ProjectID" asp-items="Model.ProjectOptions" class="form-select">
<option value="">Select project</option>
</select>
<span asp-validation-for="ProjectID" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="BookID" class="form-label">Book</label>
<select asp-for="BookID" asp-items="Model.BookOptions" class="form-select">
<option value="">All Books</option>
</select>
<span asp-validation-for="BookID" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="StartDate" class="form-label">Start Date</label>
<input asp-for="StartDate" class="form-control" type="date" />
<span asp-validation-for="StartDate" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="DeadlineDate" class="form-label">Deadline Date</label>
<input asp-for="DeadlineDate" class="form-control" type="date" />
<span asp-validation-for="DeadlineDate" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="SessionLengthMinutes" class="form-label">Session Length Minutes</label>
<input asp-for="SessionLengthMinutes" class="form-control" type="number" min="1" />
<span asp-validation-for="SessionLengthMinutes" class="text-danger"></span>
</div>
<div class="col-md-6">
<label asp-for="RebalanceMode" class="form-label">Rebalance Mode</label>
<select asp-for="RebalanceMode" asp-items="Model.RebalanceModeOptions" class="form-select"></select>
<span asp-validation-for="RebalanceMode" class="text-danger"></span>
</div>
<div class="col-12">
<label class="form-label">Writing Days</label>
<div class="writer-checkbox-grid">
@foreach (var day in Model.WritingDayOptions)
{
<label>
<input type="checkbox" name="SelectedWritingDays" value="@day" checked="@selectedDays.Contains(day)" />
@day
</label>
}
</div>
<span asp-validation-for="SelectedWritingDays" class="text-danger"></span>
</div>
<div class="col-12 writer-checkbox-grid">
<label>
<input asp-for="IncludeBlockedScenes" />
Include Blocked Scenes
</label>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Create Plan and Generate Schedule</button>
<a class="btn btn-outline-secondary" asp-action="SchedulePreview">Schedule Preview</a>
</div>
</form>
</section>
<partial name="_ValidationScriptsPartial" />