Phase 9D: Writing Schedule Setup Wizard
This commit is contained in:
parent
a56b900863
commit
218cc87327
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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">
|
||||
|
||||
106
PlotLine/Views/Writer/ScheduleSetup.cshtml
Normal file
106
PlotLine/Views/Writer/ScheduleSetup.cshtml
Normal 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" />
|
||||
Loading…
x
Reference in New Issue
Block a user