diff --git a/PlotLine/Controllers/WriterController.cs b/PlotLine/Controllers/WriterController.cs index e9c8a7c..4f83409 100644 --- a/PlotLine/Controllers/WriterController.cs +++ b/PlotLine/Controllers/WriterController.cs @@ -13,6 +13,7 @@ public sealed class WriterController( public async Task Index(int? projectId) { var model = await writerWorkspace.GetDashboardAsync(projectId); + model.WritingSchedule = await writingSchedule.GetTodayDashboardAsync(); return View(model); } @@ -108,6 +109,28 @@ public sealed class WriterController( return RedirectToAction(nameof(SchedulePreview), new { writingPlanId }); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task MarkScheduleItemComplete(int writingScheduleItemId, int? projectId) + { + var updated = await writingSchedule.MarkScheduleItemCompleteAsync(writingScheduleItemId); + TempData[updated ? "WriterWorkspaceMessage" : "WriterWorkspaceError"] = updated + ? "Writing task marked complete." + : "Only planned writing tasks can be marked complete."; + return RedirectToAction(nameof(Index), new { projectId }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SkipScheduleItem(int writingScheduleItemId, int? projectId) + { + var updated = await writingSchedule.SkipScheduleItemAsync(writingScheduleItemId); + TempData[updated ? "WriterWorkspaceMessage" : "WriterWorkspaceError"] = updated + ? "Writing task skipped." + : "Only planned writing tasks can be skipped."; + return RedirectToAction(nameof(Index), new { projectId }); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task SaveChapterGoal(ChapterGoalEditViewModel model) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 75b4760..a853bb5 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -291,6 +291,8 @@ public interface IWritingScheduleRepository Task> GetWritingPlanSummariesByUserAsync(int userId); Task> GetSchedulePreviewItemsAsync(int writingPlanId); Task DeleteScheduleItemsByPlanAsync(int writingPlanId); + Task> GetActiveScheduleDashboardItemsAsync(int userId); + Task UpdatePlannedScheduleItemStatusForUserAsync(int writingScheduleItemId, int userId, string scheduleStatus, DateTime? completedDateUtc); } public interface IExportRepository @@ -1155,6 +1157,68 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa "DELETE FROM dbo.WritingScheduleItems WHERE WritingPlanID = @WritingPlanID;", new { WritingPlanID = writingPlanId }); } + + public async Task> GetActiveScheduleDashboardItemsAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + SELECT wsi.WritingScheduleItemID, + wsi.WritingPlanID, + wp.PlanName, + wsi.SceneID, + wsi.ScheduledDate, + wsi.TaskType, + b.BookTitle, + c.ChapterNumber, + c.ChapterTitle, + c.SortOrder AS ChapterSortOrder, + s.SceneNumber, + s.SceneTitle, + s.SortOrder AS SceneSortOrder, + sw.Priority, + wsi.EstimatedMinutes, + wsi.TargetWords, + wsi.ScheduleStatus, + COALESCE(sw.IsBlocked, 0) AS IsBlocked + FROM dbo.WritingPlans wp + INNER JOIN dbo.WritingScheduleItems wsi ON wsi.WritingPlanID = wp.WritingPlanID + INNER JOIN dbo.Scenes s ON s.SceneID = wsi.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + LEFT JOIN dbo.SceneWorkflow sw ON sw.SceneID = s.SceneID + WHERE wp.UserID = @UserID + AND wp.IsActive = 1 + ORDER BY wsi.ScheduledDate, wsi.WritingScheduleItemID; + """, + new { UserID = userId }); + return rows.ToList(); + } + + public async Task UpdatePlannedScheduleItemStatusForUserAsync(int writingScheduleItemId, int userId, string scheduleStatus, DateTime? completedDateUtc) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.ExecuteAsync( + """ + UPDATE wsi + SET ScheduleStatus = @ScheduleStatus, + CompletedDateUtc = @CompletedDateUtc, + ModifiedDateUtc = SYSUTCDATETIME() + FROM dbo.WritingScheduleItems wsi + INNER JOIN dbo.WritingPlans wp ON wp.WritingPlanID = wsi.WritingPlanID + WHERE wsi.WritingScheduleItemID = @WritingScheduleItemID + AND wp.UserID = @UserID + AND wp.IsActive = 1 + AND wsi.ScheduleStatus = N'Planned'; + """, + new + { + WritingScheduleItemID = writingScheduleItemId, + UserID = userId, + ScheduleStatus = scheduleStatus, + CompletedDateUtc = completedDateUtc + }); + } } public sealed class ExportRepository(ISqlConnectionFactory connectionFactory) : IExportRepository diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 61c516a..6446fc8 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -1651,6 +1651,28 @@ public sealed class WritingSchedulePreviewItem public bool IsBlocked { get; set; } } +public sealed class WritingScheduleDashboardItem +{ + public int WritingScheduleItemID { get; set; } + public int WritingPlanID { get; set; } + public string PlanName { get; set; } = string.Empty; + public int SceneID { get; set; } + public DateTime ScheduledDate { get; set; } + public string TaskType { get; set; } = string.Empty; + public string BookTitle { get; set; } = string.Empty; + public decimal ChapterNumber { get; set; } + public string ChapterTitle { get; set; } = string.Empty; + public int ChapterSortOrder { get; set; } + public decimal SceneNumber { get; set; } + public string SceneTitle { get; set; } = string.Empty; + public int SceneSortOrder { get; set; } + public int? Priority { get; set; } + public int? EstimatedMinutes { get; set; } + public int? TargetWords { get; set; } + public string ScheduleStatus { get; set; } = string.Empty; + public bool IsBlocked { get; set; } +} + public sealed class ChapterWorkflowScene { public int SceneID { get; set; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 2837c1c..c13453d 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -277,6 +277,9 @@ public interface IWritingScheduleService Task GetScheduleSetupAsync(int? projectId); Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model); Task CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model); + Task GetTodayDashboardAsync(); + Task MarkScheduleItemCompleteAsync(int writingScheduleItemId); + Task SkipScheduleItemAsync(int writingScheduleItemId); } public interface IStorageService @@ -3528,6 +3531,76 @@ public sealed class WritingScheduleService( } } + public async Task GetTodayDashboardAsync() + { + if (!currentUser.UserId.HasValue) + { + return new WritingScheduleDashboardViewModel(); + } + + var plans = await writingSchedule.GetWritingPlanSummariesByUserAsync(currentUser.UserId.Value); + var activePlanCount = plans.Count(x => x.IsActive); + var items = await writingSchedule.GetActiveScheduleDashboardItemsAsync(currentUser.UserId.Value); + var today = DateTime.Today; + var plannedItems = items + .Where(x => string.Equals(x.ScheduleStatus, "Planned", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + return new WritingScheduleDashboardViewModel + { + ActivePlanCount = activePlanCount, + TodayTasks = plannedItems + .Where(x => x.ScheduledDate.Date == today) + .OrderByDescending(x => x.Priority ?? 0) + .ThenBy(x => x.ScheduledDate) + .ThenBy(x => x.ChapterSortOrder) + .ThenBy(x => x.SceneSortOrder) + .ThenBy(x => x.WritingScheduleItemID) + .ToList(), + UpcomingTasks = plannedItems + .Where(x => x.ScheduledDate.Date > today) + .OrderBy(x => x.ScheduledDate) + .ThenBy(x => x.WritingScheduleItemID) + .Take(5) + .ToList(), + TotalPlannedTasks = items.Count, + CompletedTasks = items.Count(x => string.Equals(x.ScheduleStatus, "Done", StringComparison.OrdinalIgnoreCase)), + SkippedTasks = items.Count(x => string.Equals(x.ScheduleStatus, "Skipped", StringComparison.OrdinalIgnoreCase)), + RemainingTasks = plannedItems.Count, + ProjectedFinishDate = plannedItems.Count == 0 ? null : plannedItems.Max(x => x.ScheduledDate) + }; + } + + public async Task MarkScheduleItemCompleteAsync(int writingScheduleItemId) + { + if (!currentUser.UserId.HasValue) + { + return false; + } + + var affectedRows = await writingSchedule.UpdatePlannedScheduleItemStatusForUserAsync( + writingScheduleItemId, + currentUser.UserId.Value, + "Done", + DateTime.UtcNow); + return affectedRows > 0; + } + + public async Task SkipScheduleItemAsync(int writingScheduleItemId) + { + if (!currentUser.UserId.HasValue) + { + return false; + } + + var affectedRows = await writingSchedule.UpdatePlannedScheduleItemStatusForUserAsync( + writingScheduleItemId, + currentUser.UserId.Value, + "Skipped", + null); + return affectedRows > 0; + } + private static void ValidateSetup(WritingScheduleSetupViewModel model) { if (string.IsNullOrWhiteSpace(model.PlanName)) diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index a862c2d..f3ba84f 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -314,6 +314,7 @@ public sealed class WriterDashboardViewModel { public int? ProjectID { get; set; } public IReadOnlyList ProjectOptions { get; set; } = []; + public WritingScheduleDashboardViewModel WritingSchedule { get; set; } = new(); public IReadOnlyList WriteNext { get; set; } = []; public IReadOnlyList ContinueWriting { get; set; } = []; public IReadOnlyList RevisionQueue { get; set; } = []; @@ -321,6 +322,20 @@ public sealed class WriterDashboardViewModel public IReadOnlyList StoryHealthReminders { get; set; } = []; } +public sealed class WritingScheduleDashboardViewModel +{ + public int ActivePlanCount { get; set; } + public IReadOnlyList TodayTasks { get; set; } = []; + public IReadOnlyList UpcomingTasks { get; set; } = []; + public int TotalPlannedTasks { get; set; } + public int CompletedTasks { get; set; } + public int SkippedTasks { get; set; } + public int RemainingTasks { get; set; } + public DateTime? ProjectedFinishDate { get; set; } + public bool HasActivePlans => ActivePlanCount > 0; + public bool HasActiveSchedule => TotalPlannedTasks > 0; +} + public sealed class WritingPlanViewModel { public int WritingPlanID { get; set; } diff --git a/PlotLine/Views/Writer/Index.cshtml b/PlotLine/Views/Writer/Index.cshtml index 4454894..45cf694 100644 --- a/PlotLine/Views/Writer/Index.cshtml +++ b/PlotLine/Views/Writer/Index.cshtml @@ -1,6 +1,8 @@ @model WriterDashboardViewModel @{ ViewData["Title"] = "Writer Workspace"; + var message = TempData["WriterWorkspaceMessage"] as string; + var error = TempData["WriterWorkspaceError"] as string; }
@@ -21,6 +23,156 @@
+@if (!string.IsNullOrWhiteSpace(message)) +{ +
@message
+} + +@if (!string.IsNullOrWhiteSpace(error)) +{ +
@error
+} + +
+
+
+

Daily plan

+

Today's Writing

+
+ @Model.WritingSchedule.TodayTasks.Count tasks +
+ + @if (!Model.WritingSchedule.HasActivePlans) + { +

No writing schedule configured.

+ Create Writing Schedule + } + else + { + @if (!Model.WritingSchedule.TodayTasks.Any()) + { +

No writing tasks scheduled for today.

+ } + else + { +
+ + + + + + + + + + + + + + + + + @foreach (var item in Model.WritingSchedule.TodayTasks) + { + + + + + + + + + + + + + } + +
Task TypeBookChapterScenePriorityEstimated MinutesTarget WordsBlockedWriting Plan
@item.TaskType@item.BookTitleChapter @item.ChapterNumber: @item.ChapterTitleScene @item.SceneNumber: @item.SceneTitle@(item.Priority?.ToString() ?? "-")@(item.EstimatedMinutes?.ToString() ?? "-")@(item.TargetWords?.ToString() ?? "-")@(item.IsBlocked ? "Yes" : "No")@item.PlanName +
+
+ + + +
+
+ + + +
+
+
+
+ } + +
+
+

Upcoming work

+

Coming Up

+
+ @Model.WritingSchedule.UpcomingTasks.Count tasks +
+ + @if (!Model.WritingSchedule.UpcomingTasks.Any()) + { +

No upcoming writing tasks.

+ } + else + { +
+ + + + + + + + + + + + + + @foreach (var item in Model.WritingSchedule.UpcomingTasks) + { + + + + + + + + + + } + +
Scheduled DateTask TypeBookChapterSceneEstimated MinutesTarget Words
@item.ScheduledDate.ToString("dd MMM yyyy")@item.TaskType@item.BookTitleChapter @item.ChapterNumber: @item.ChapterTitleScene @item.SceneNumber: @item.SceneTitle@(item.EstimatedMinutes?.ToString() ?? "-")@(item.TargetWords?.ToString() ?? "-")
+
+ } + +
+
+

Schedule

+

Progress Summary

+
+
+ + @if (!Model.WritingSchedule.HasActiveSchedule) + { +

No active schedule

+ } + +
+

Active Plans

@Model.WritingSchedule.ActivePlanCount
+

Total Planned Tasks

@Model.WritingSchedule.TotalPlannedTasks
+

Completed Tasks

@Model.WritingSchedule.CompletedTasks
+

Skipped Tasks

@Model.WritingSchedule.SkippedTasks
+

Remaining Tasks

@Model.WritingSchedule.RemainingTasks
+

Projected Finish Date

@(Model.WritingSchedule.ProjectedFinishDate?.ToString("dd MMM yyyy") ?? "-")
+
+ } +
+