Phase 9E: Daily Writing Dashboard.

This commit is contained in:
Nick Beckley 2026-06-12 22:00:10 +01:00
parent 218cc87327
commit e83425eaac
6 changed files with 349 additions and 0 deletions

View File

@ -13,6 +13,7 @@ public sealed class WriterController(
public async Task<IActionResult> Index(int? projectId) public async Task<IActionResult> Index(int? projectId)
{ {
var model = await writerWorkspace.GetDashboardAsync(projectId); var model = await writerWorkspace.GetDashboardAsync(projectId);
model.WritingSchedule = await writingSchedule.GetTodayDashboardAsync();
return View(model); return View(model);
} }
@ -108,6 +109,28 @@ public sealed class WriterController(
return RedirectToAction(nameof(SchedulePreview), new { writingPlanId }); return RedirectToAction(nameof(SchedulePreview), new { writingPlanId });
} }
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> 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] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> SaveChapterGoal(ChapterGoalEditViewModel model) public async Task<IActionResult> SaveChapterGoal(ChapterGoalEditViewModel model)

View File

@ -291,6 +291,8 @@ public interface IWritingScheduleRepository
Task<IReadOnlyList<WritingPlanSummary>> GetWritingPlanSummariesByUserAsync(int userId); Task<IReadOnlyList<WritingPlanSummary>> GetWritingPlanSummariesByUserAsync(int userId);
Task<IReadOnlyList<WritingSchedulePreviewItem>> GetSchedulePreviewItemsAsync(int writingPlanId); Task<IReadOnlyList<WritingSchedulePreviewItem>> GetSchedulePreviewItemsAsync(int writingPlanId);
Task DeleteScheduleItemsByPlanAsync(int writingPlanId); Task DeleteScheduleItemsByPlanAsync(int writingPlanId);
Task<IReadOnlyList<WritingScheduleDashboardItem>> GetActiveScheduleDashboardItemsAsync(int userId);
Task<int> UpdatePlannedScheduleItemStatusForUserAsync(int writingScheduleItemId, int userId, string scheduleStatus, DateTime? completedDateUtc);
} }
public interface IExportRepository public interface IExportRepository
@ -1155,6 +1157,68 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa
"DELETE FROM dbo.WritingScheduleItems WHERE WritingPlanID = @WritingPlanID;", "DELETE FROM dbo.WritingScheduleItems WHERE WritingPlanID = @WritingPlanID;",
new { WritingPlanID = writingPlanId }); new { WritingPlanID = writingPlanId });
} }
public async Task<IReadOnlyList<WritingScheduleDashboardItem>> GetActiveScheduleDashboardItemsAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<WritingScheduleDashboardItem>(
"""
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<int> 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 public sealed class ExportRepository(ISqlConnectionFactory connectionFactory) : IExportRepository

View File

@ -1651,6 +1651,28 @@ public sealed class WritingSchedulePreviewItem
public bool IsBlocked { get; set; } 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 sealed class ChapterWorkflowScene
{ {
public int SceneID { get; set; } public int SceneID { get; set; }

View File

@ -277,6 +277,9 @@ public interface IWritingScheduleService
Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId); Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId);
Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model); Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model);
Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model); Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model);
Task<WritingScheduleDashboardViewModel> GetTodayDashboardAsync();
Task<bool> MarkScheduleItemCompleteAsync(int writingScheduleItemId);
Task<bool> SkipScheduleItemAsync(int writingScheduleItemId);
} }
public interface IStorageService public interface IStorageService
@ -3528,6 +3531,76 @@ public sealed class WritingScheduleService(
} }
} }
public async Task<WritingScheduleDashboardViewModel> 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<bool> 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<bool> 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) private static void ValidateSetup(WritingScheduleSetupViewModel model)
{ {
if (string.IsNullOrWhiteSpace(model.PlanName)) if (string.IsNullOrWhiteSpace(model.PlanName))

View File

@ -314,6 +314,7 @@ public sealed class WriterDashboardViewModel
{ {
public int? ProjectID { get; set; } public int? ProjectID { get; set; }
public IReadOnlyList<SelectListItem> ProjectOptions { get; set; } = []; public IReadOnlyList<SelectListItem> ProjectOptions { get; set; } = [];
public WritingScheduleDashboardViewModel WritingSchedule { get; set; } = new();
public IReadOnlyList<WriterDashboardScene> WriteNext { get; set; } = []; public IReadOnlyList<WriterDashboardScene> WriteNext { get; set; } = [];
public IReadOnlyList<WriterDashboardScene> ContinueWriting { get; set; } = []; public IReadOnlyList<WriterDashboardScene> ContinueWriting { get; set; } = [];
public IReadOnlyList<WriterDashboardScene> RevisionQueue { get; set; } = []; public IReadOnlyList<WriterDashboardScene> RevisionQueue { get; set; } = [];
@ -321,6 +322,20 @@ public sealed class WriterDashboardViewModel
public IReadOnlyList<StoryBibleAttentionItem> StoryHealthReminders { get; set; } = []; public IReadOnlyList<StoryBibleAttentionItem> StoryHealthReminders { get; set; } = [];
} }
public sealed class WritingScheduleDashboardViewModel
{
public int ActivePlanCount { get; set; }
public IReadOnlyList<WritingScheduleDashboardItem> TodayTasks { get; set; } = [];
public IReadOnlyList<WritingScheduleDashboardItem> 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 sealed class WritingPlanViewModel
{ {
public int WritingPlanID { get; set; } public int WritingPlanID { get; set; }

View File

@ -1,6 +1,8 @@
@model WriterDashboardViewModel @model WriterDashboardViewModel
@{ @{
ViewData["Title"] = "Writer Workspace"; ViewData["Title"] = "Writer Workspace";
var message = TempData["WriterWorkspaceMessage"] as string;
var error = TempData["WriterWorkspaceError"] as string;
} }
<div class="page-heading writer-dashboard-heading"> <div class="page-heading writer-dashboard-heading">
@ -21,6 +23,156 @@
</div> </div>
</div> </div>
@if (!string.IsNullOrWhiteSpace(message))
{
<div class="alert alert-info">@message</div>
}
@if (!string.IsNullOrWhiteSpace(error))
{
<div class="alert alert-warning">@error</div>
}
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">Daily plan</p>
<h2>Today's Writing</h2>
</div>
<span class="soft-count">@Model.WritingSchedule.TodayTasks.Count tasks</span>
</div>
@if (!Model.WritingSchedule.HasActivePlans)
{
<p class="muted">No writing schedule configured.</p>
<a class="btn btn-primary" asp-action="ScheduleSetup" asp-route-projectId="@Model.ProjectID">Create Writing Schedule</a>
}
else
{
@if (!Model.WritingSchedule.TodayTasks.Any())
{
<p class="muted">No writing tasks scheduled for today.</p>
}
else
{
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Task Type</th>
<th>Book</th>
<th>Chapter</th>
<th>Scene</th>
<th>Priority</th>
<th>Estimated Minutes</th>
<th>Target Words</th>
<th>Blocked</th>
<th>Writing Plan</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.WritingSchedule.TodayTasks)
{
<tr>
<td>@item.TaskType</td>
<td>@item.BookTitle</td>
<td>Chapter @item.ChapterNumber: @item.ChapterTitle</td>
<td>Scene @item.SceneNumber: @item.SceneTitle</td>
<td>@(item.Priority?.ToString() ?? "-")</td>
<td>@(item.EstimatedMinutes?.ToString() ?? "-")</td>
<td>@(item.TargetWords?.ToString() ?? "-")</td>
<td>@(item.IsBlocked ? "Yes" : "No")</td>
<td>@item.PlanName</td>
<td>
<div class="button-row">
<form asp-action="MarkScheduleItemComplete" method="post">
<input type="hidden" name="writingScheduleItemId" value="@item.WritingScheduleItemID" />
<input type="hidden" name="projectId" value="@Model.ProjectID" />
<button class="btn btn-sm btn-primary" type="submit">Mark Complete</button>
</form>
<form asp-action="SkipScheduleItem" method="post">
<input type="hidden" name="writingScheduleItemId" value="@item.WritingScheduleItemID" />
<input type="hidden" name="projectId" value="@Model.ProjectID" />
<button class="btn btn-sm btn-outline-secondary" type="submit">Skip</button>
</form>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="story-bible-section-heading mt-4">
<div>
<p class="eyebrow">Upcoming work</p>
<h2>Coming Up</h2>
</div>
<span class="soft-count">@Model.WritingSchedule.UpcomingTasks.Count tasks</span>
</div>
@if (!Model.WritingSchedule.UpcomingTasks.Any())
{
<p class="muted">No upcoming writing tasks.</p>
}
else
{
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Scheduled Date</th>
<th>Task Type</th>
<th>Book</th>
<th>Chapter</th>
<th>Scene</th>
<th>Estimated Minutes</th>
<th>Target Words</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.WritingSchedule.UpcomingTasks)
{
<tr>
<td>@item.ScheduledDate.ToString("dd MMM yyyy")</td>
<td>@item.TaskType</td>
<td>@item.BookTitle</td>
<td>Chapter @item.ChapterNumber: @item.ChapterTitle</td>
<td>Scene @item.SceneNumber: @item.SceneTitle</td>
<td>@(item.EstimatedMinutes?.ToString() ?? "-")</td>
<td>@(item.TargetWords?.ToString() ?? "-")</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="story-bible-section-heading mt-4">
<div>
<p class="eyebrow">Schedule</p>
<h2>Progress Summary</h2>
</div>
</div>
@if (!Model.WritingSchedule.HasActiveSchedule)
{
<p class="muted">No active schedule</p>
}
<div class="story-bible-grid story-bible-grid-compact">
<article class="story-bible-card"><h3>Active Plans</h3><strong class="writer-kpi">@Model.WritingSchedule.ActivePlanCount</strong></article>
<article class="story-bible-card"><h3>Total Planned Tasks</h3><strong class="writer-kpi">@Model.WritingSchedule.TotalPlannedTasks</strong></article>
<article class="story-bible-card"><h3>Completed Tasks</h3><strong class="writer-kpi">@Model.WritingSchedule.CompletedTasks</strong></article>
<article class="story-bible-card"><h3>Skipped Tasks</h3><strong class="writer-kpi">@Model.WritingSchedule.SkippedTasks</strong></article>
<article class="story-bible-card"><h3>Remaining Tasks</h3><strong class="writer-kpi">@Model.WritingSchedule.RemainingTasks</strong></article>
<article class="story-bible-card"><h3>Projected Finish Date</h3><strong class="writer-kpi">@(Model.WritingSchedule.ProjectedFinishDate?.ToString("dd MMM yyyy") ?? "-")</strong></article>
</div>
}
</section>
<div class="writer-dashboard-grid"> <div class="writer-dashboard-grid">
<section class="writer-dashboard-panel"> <section class="writer-dashboard-panel">
<div class="story-bible-section-heading"> <div class="story-bible-section-heading">