Phase 9E: Daily Writing Dashboard.
This commit is contained in:
parent
218cc87327
commit
e83425eaac
@ -13,6 +13,7 @@ public sealed class WriterController(
|
||||
public async Task<IActionResult> 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<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]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SaveChapterGoal(ChapterGoalEditViewModel model)
|
||||
|
||||
@ -291,6 +291,8 @@ public interface IWritingScheduleRepository
|
||||
Task<IReadOnlyList<WritingPlanSummary>> GetWritingPlanSummariesByUserAsync(int userId);
|
||||
Task<IReadOnlyList<WritingSchedulePreviewItem>> GetSchedulePreviewItemsAsync(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
|
||||
@ -1155,6 +1157,68 @@ public sealed class WritingScheduleRepository(ISqlConnectionFactory connectionFa
|
||||
"DELETE FROM dbo.WritingScheduleItems WHERE 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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -277,6 +277,9 @@ public interface IWritingScheduleService
|
||||
Task<WritingScheduleSetupViewModel> GetScheduleSetupAsync(int? projectId);
|
||||
Task PopulateScheduleSetupOptionsAsync(WritingScheduleSetupViewModel model);
|
||||
Task<WritingScheduleSetupResult> CreateScheduleFromSetupAsync(WritingScheduleSetupViewModel model);
|
||||
Task<WritingScheduleDashboardViewModel> GetTodayDashboardAsync();
|
||||
Task<bool> MarkScheduleItemCompleteAsync(int writingScheduleItemId);
|
||||
Task<bool> SkipScheduleItemAsync(int writingScheduleItemId);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(model.PlanName))
|
||||
|
||||
@ -314,6 +314,7 @@ public sealed class WriterDashboardViewModel
|
||||
{
|
||||
public int? ProjectID { get; set; }
|
||||
public IReadOnlyList<SelectListItem> ProjectOptions { get; set; } = [];
|
||||
public WritingScheduleDashboardViewModel WritingSchedule { get; set; } = new();
|
||||
public IReadOnlyList<WriterDashboardScene> WriteNext { get; set; } = [];
|
||||
public IReadOnlyList<WriterDashboardScene> ContinueWriting { get; set; } = [];
|
||||
public IReadOnlyList<WriterDashboardScene> RevisionQueue { get; set; } = [];
|
||||
@ -321,6 +322,20 @@ public sealed class WriterDashboardViewModel
|
||||
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 int WritingPlanID { get; set; }
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
@model WriterDashboardViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Writer Workspace";
|
||||
var message = TempData["WriterWorkspaceMessage"] as string;
|
||||
var error = TempData["WriterWorkspaceError"] as string;
|
||||
}
|
||||
|
||||
<div class="page-heading writer-dashboard-heading">
|
||||
@ -21,6 +23,156 @@
|
||||
</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">
|
||||
<section class="writer-dashboard-panel">
|
||||
<div class="story-bible-section-heading">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user