diff --git a/PlotLine/Controllers/WriterController.cs b/PlotLine/Controllers/WriterController.cs index 1369d1b..e9c8a7c 100644 --- a/PlotLine/Controllers/WriterController.cs +++ b/PlotLine/Controllers/WriterController.cs @@ -28,6 +28,60 @@ public sealed class WriterController( return View(model); } + public async Task ScheduleSetup(int? projectId) + { + var model = await writingSchedule.GetScheduleSetupAsync(projectId); + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task 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 GenerateSchedule(int writingPlanId) diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index f23fa2b..2837c1c 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -274,6 +274,9 @@ public interface IWritingScheduleService Task> GenerateSchedule(int writingPlanId); Task GetSchedulePreviewAsync(int? writingPlanId); Task DeleteScheduleAsync(int writingPlanId); + Task GetScheduleSetupAsync(int? projectId); + Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model); + Task 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> GetWritingPlansByUserAsync(int userId) @@ -3417,6 +3422,151 @@ public sealed class WritingScheduleService( } } + public async Task 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 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 NormaliseWritingDays(IEnumerable? 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 ScheduleOptionList(IEnumerable 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) diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index bcb034d..a862c2d 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -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 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 ProjectOptions { get; set; } = []; + public IReadOnlyList BookOptions { get; set; } = []; + public IReadOnlyList GoalTypeOptions { get; set; } = []; + public IReadOnlyList RebalanceModeOptions { get; set; } = []; + public IReadOnlyList 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(); diff --git a/PlotLine/Views/Writer/Index.cshtml b/PlotLine/Views/Writer/Index.cshtml index 342637f..4454894 100644 --- a/PlotLine/Views/Writer/Index.cshtml +++ b/PlotLine/Views/Writer/Index.cshtml @@ -10,6 +10,7 @@

A practical control room for drafting, revision, polish, and story readiness.

+ Schedule Setup Schedule Preview
+ +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+ @foreach (var day in Model.WritingDayOptions) + { + + } +
+ +
+ +
+ +
+ + +
+ + Schedule Preview +
+ + + +