Phase 13D: Series Dashboard Activity and Attention Widgets.

This commit is contained in:
Nick Beckley 2026-06-15 15:45:04 +01:00
parent 831bdf542d
commit d872289e6f
5 changed files with 437 additions and 1 deletions

View File

@ -15,6 +15,7 @@ public interface IProjectRepository
Task<Project?> GetForUserAsync(int projectId, int userId); Task<Project?> GetForUserAsync(int projectId, int userId);
Task<Project?> GetArchivedForOwnerAsync(int projectId, int userId); Task<Project?> GetArchivedForOwnerAsync(int projectId, int userId);
Task<ProjectDashboardMetrics> GetDashboardMetricsAsync(int projectId); Task<ProjectDashboardMetrics> GetDashboardMetricsAsync(int projectId);
Task<IReadOnlyList<ProjectDashboardAttentionThread>> ListDashboardAttentionThreadsAsync(int projectId, int count);
Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync(); Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync();
Task<int> SaveAsync(Project project); Task<int> SaveAsync(Project project);
Task<int> SaveAsync(Project project, int userId, string? genreMetricPresetKey); Task<int> SaveAsync(Project project, int userId, string? genreMetricPresetKey);
@ -43,6 +44,7 @@ public interface IProjectCollaborationRepository
public interface IProjectActivityRepository public interface IProjectActivityRepository
{ {
Task<IReadOnlyList<ProjectActivity>> ListForProjectAsync(int projectId); Task<IReadOnlyList<ProjectActivity>> ListForProjectAsync(int projectId);
Task<IReadOnlyList<ProjectActivity>> ListRecentForProjectAsync(int projectId, int count);
Task<long> RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null); Task<long> RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null);
} }
@ -218,6 +220,7 @@ public interface IWarningRepository
Task<ContinuityValidationSummary> ValidateBookAsync(int bookId); Task<ContinuityValidationSummary> ValidateBookAsync(int bookId);
Task<ContinuityValidationSummary> ValidateSceneAsync(int sceneId); Task<ContinuityValidationSummary> ValidateSceneAsync(int sceneId);
Task<IReadOnlyList<ContinuityWarning>> ListAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional); Task<IReadOnlyList<ContinuityWarning>> ListAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional);
Task<IReadOnlyList<ContinuityWarning>> ListDashboardUnresolvedAsync(int projectId, int count);
Task<IReadOnlyList<ContinuityWarning>> ListBySceneAsync(int sceneId); Task<IReadOnlyList<ContinuityWarning>> ListBySceneAsync(int sceneId);
Task<IReadOnlyList<SceneWarningCount>> GetSceneCountsAsync(int projectId, int? bookId); Task<IReadOnlyList<SceneWarningCount>> GetSceneCountsAsync(int projectId, int? bookId);
Task DismissAsync(int continuityWarningId); Task DismissAsync(int continuityWarningId);
@ -578,6 +581,50 @@ public sealed class WarningRepository(ISqlConnectionFactory connectionFactory) :
return rows.ToList(); return rows.ToList();
} }
public async Task<IReadOnlyList<ContinuityWarning>> ListDashboardUnresolvedAsync(int projectId, int count)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<ContinuityWarning>(
"""
SELECT TOP (@Count)
cw.ContinuityWarningID,
cw.ValidationRunID,
cw.ProjectID,
cw.BookID,
b.BookTitle,
cw.ChapterID,
c.ChapterNumber,
c.ChapterTitle,
cw.SceneID,
s.SceneNumber,
s.SceneTitle,
cw.EntityType,
cw.EntityID,
cw.WarningTypeID,
wt.TypeName AS WarningTypeName,
cw.WarningSeverityID,
ws.SeverityName,
cw.Message,
cw.Details,
cw.IsDismissed,
cw.IsIntentional,
cw.CreatedDate,
cw.LastDetectedDate
FROM dbo.ContinuityWarnings cw
INNER JOIN dbo.WarningTypes wt ON wt.WarningTypeID = cw.WarningTypeID
INNER JOIN dbo.WarningSeverities ws ON ws.WarningSeverityID = cw.WarningSeverityID
LEFT JOIN dbo.Books b ON b.BookID = cw.BookID
LEFT JOIN dbo.Chapters c ON c.ChapterID = cw.ChapterID
LEFT JOIN dbo.Scenes s ON s.SceneID = cw.SceneID
WHERE cw.ProjectID = @ProjectID
AND cw.IsDismissed = 0
AND cw.IsIntentional = 0
ORDER BY ws.SortOrder DESC, cw.LastDetectedDate DESC, cw.ContinuityWarningID DESC;
""",
new { ProjectID = projectId, Count = count });
return rows.ToList();
}
public async Task<IReadOnlyList<ContinuityWarning>> ListBySceneAsync(int sceneId) public async Task<IReadOnlyList<ContinuityWarning>> ListBySceneAsync(int sceneId)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -1859,6 +1906,40 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
new { ProjectID = projectId }); new { ProjectID = projectId });
} }
public async Task<IReadOnlyList<ProjectDashboardAttentionThread>> ListDashboardAttentionThreadsAsync(int projectId, int count)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<ProjectDashboardAttentionThread>(
"""
SELECT TOP (@Count)
pt.PlotThreadID,
pt.ThreadTitle,
pt.PlotLineID,
pl.PlotLineName,
pt.Importance,
ts.StatusName AS ThreadStatusName,
ts.IsOpenStatus,
ts.IsResolvedStatus
FROM dbo.PlotThreads pt
INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID
INNER JOIN dbo.ThreadStatuses ts ON ts.ThreadStatusID = pt.ThreadStatusID
WHERE pl.ProjectID = @ProjectID
AND pl.IsArchived = 0
AND pt.IsArchived = 0
AND (
ts.StatusName = N'Dormant'
OR (ts.IsOpenStatus = 1 AND pt.Importance >= 8)
)
ORDER BY
CASE WHEN ts.StatusName = N'Dormant' THEN 0 ELSE 1 END,
pt.Importance DESC,
pt.UpdatedDate DESC,
pt.PlotThreadID DESC;
""",
new { ProjectID = projectId, Count = count });
return rows.ToList();
}
public async Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync() public async Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync()
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -2034,6 +2115,32 @@ public sealed class ProjectActivityRepository(ISqlConnectionFactory connectionFa
return rows.ToList(); return rows.ToList();
} }
public async Task<IReadOnlyList<ProjectActivity>> ListRecentForProjectAsync(int projectId, int count)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<ProjectActivity>(
"""
SELECT TOP (@Count)
pa.ProjectActivityID,
pa.ProjectID,
pa.UserID,
u.DisplayName,
u.Email,
pa.ActivityType,
pa.EntityType,
pa.EntityID,
pa.EntityName,
pa.Description,
pa.CreatedDateUTC
FROM dbo.ProjectActivity pa
LEFT JOIN dbo.AppUser u ON u.UserID = pa.UserID
WHERE pa.ProjectID = @ProjectID
ORDER BY pa.CreatedDateUTC DESC, pa.ProjectActivityID DESC;
""",
new { ProjectID = projectId, Count = count });
return rows.ToList();
}
public async Task<long> RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null) public async Task<long> RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -75,6 +75,18 @@ public sealed class ProjectDashboardMetrics
public int ContinuityWarningCount { get; set; } public int ContinuityWarningCount { get; set; }
} }
public sealed class ProjectDashboardAttentionThread
{
public int PlotThreadID { get; set; }
public string ThreadTitle { get; set; } = string.Empty;
public int PlotLineID { get; set; }
public string PlotLineName { get; set; } = string.Empty;
public int Importance { get; set; }
public string ThreadStatusName { get; set; } = string.Empty;
public bool IsOpenStatus { get; set; }
public bool IsResolvedStatus { get; set; }
}
public sealed class ProjectHardDeleteResult public sealed class ProjectHardDeleteResult
{ {
public bool Succeeded { get; init; } public bool Succeeded { get; init; }

View File

@ -368,6 +368,9 @@ public interface IAcceptanceService
public sealed class ProjectService( public sealed class ProjectService(
IProjectRepository projects, IProjectRepository projects,
IBookRepository books, IBookRepository books,
IProjectActivityRepository projectActivity,
IWarningRepository warnings,
IWritingScheduleRepository writingSchedule,
IProjectActivityService activity, IProjectActivityService activity,
ICurrentUserService currentUser, ICurrentUserService currentUser,
IHardDeleteFileCleanupService fileCleanup, IHardDeleteFileCleanupService fileCleanup,
@ -389,7 +392,13 @@ public sealed class ProjectService(
public async Task<ProjectDetailViewModel?> GetDetailAsync(int projectId) public async Task<ProjectDetailViewModel?> GetDetailAsync(int projectId)
{ {
var project = currentUser.UserId.HasValue ? await projects.GetForUserAsync(projectId, currentUser.UserId.Value) : null; if (!currentUser.UserId.HasValue)
{
return null;
}
var userId = currentUser.UserId.Value;
var project = await projects.GetForUserAsync(projectId, userId);
if (project is null) if (project is null)
{ {
return null; return null;
@ -397,6 +406,10 @@ public sealed class ProjectService(
var projectBooks = await books.ListByProjectAsync(projectId); var projectBooks = await books.ListByProjectAsync(projectId);
var metrics = await projects.GetDashboardMetricsAsync(projectId); var metrics = await projects.GetDashboardMetricsAsync(projectId);
var recentActivity = await projectActivity.ListRecentForProjectAsync(projectId, 10);
var dashboardWarnings = await warnings.ListDashboardUnresolvedAsync(projectId, 5);
var attentionThreads = await projects.ListDashboardAttentionThreadsAsync(projectId, 5);
var writingPlans = await writingSchedule.GetWritingPlanSummariesByUserAsync(userId, projectId);
return new ProjectDetailViewModel return new ProjectDetailViewModel
{ {
@ -413,6 +426,25 @@ public sealed class ProjectService(
ActivePlotThreadCount = metrics.ActivePlotThreadCount, ActivePlotThreadCount = metrics.ActivePlotThreadCount,
ContinuityWarningCount = metrics.ContinuityWarningCount ContinuityWarningCount = metrics.ContinuityWarningCount
}, },
RecentActivity = recentActivity.Select(ToDashboardActivityItem).ToList(),
NeedsAttention = new ProjectDashboardAttentionViewModel
{
Warnings = dashboardWarnings.Select(ToDashboardWarningItem).ToList(),
Threads = attentionThreads.Select(ToDashboardThreadItem).ToList(),
OverdueBooks = projectBooks
.Where(IsOverdueDashboardBook)
.OrderBy(book => book.TargetCompletionDate)
.ThenBy(book => book.BookNumber)
.Take(5)
.Select(book => new ProjectDashboardOverdueBookViewModel
{
BookID = book.BookID,
BookTitle = book.BookTitle,
TargetCompletionDate = book.TargetCompletionDate!.Value
})
.ToList(),
ShowMissingSchedulePrompt = !writingPlans.Any(x => x.IsActive)
},
Books = projectBooks.Select(book => new ProjectDashboardBookCardViewModel Books = projectBooks.Select(book => new ProjectDashboardBookCardViewModel
{ {
BookID = book.BookID, BookID = book.BookID,
@ -431,6 +463,108 @@ public sealed class ProjectService(
}; };
} }
private static ProjectDashboardActivityItemViewModel ToDashboardActivityItem(ProjectActivity item)
{
return new ProjectDashboardActivityItemViewModel
{
ActivityType = item.ActivityType,
EntityType = item.EntityType,
EntityName = item.EntityName,
Description = item.Description,
UserLabel = item.UserLabel,
TimeLabel = RelativeTimeLabel(item.CreatedDateUTC),
IconLabel = DashboardIconLabel(item.EntityType, item.ActivityType)
};
}
private static ProjectDashboardWarningItemViewModel ToDashboardWarningItem(ContinuityWarning warning)
{
return new ProjectDashboardWarningItemViewModel
{
SeverityName = warning.SeverityName,
WarningTypeName = warning.WarningTypeName,
Message = warning.Message,
LocationLabel = DashboardWarningLocation(warning)
};
}
private static ProjectDashboardThreadItemViewModel ToDashboardThreadItem(ProjectDashboardAttentionThread thread)
{
return new ProjectDashboardThreadItemViewModel
{
PlotThreadID = thread.PlotThreadID,
ThreadTitle = thread.ThreadTitle,
PlotLineName = thread.PlotLineName,
ThreadStatusName = thread.ThreadStatusName,
Importance = thread.Importance,
AttentionLabel = string.Equals(thread.ThreadStatusName, "Dormant", StringComparison.OrdinalIgnoreCase)
? "Dormant"
: "High importance open"
};
}
private static bool IsOverdueDashboardBook(Book book)
{
return book.TargetCompletionDate.HasValue
&& book.TargetCompletionDate.Value.Date < DateTime.Today
&& !string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(book.Status, BookStatuses.Archived, StringComparison.OrdinalIgnoreCase);
}
private static string DashboardIconLabel(string entityType, string activityType)
{
var source = string.IsNullOrWhiteSpace(entityType) ? activityType : entityType;
return string.IsNullOrWhiteSpace(source) ? "A" : source.Trim()[0].ToString().ToUpperInvariant();
}
private static string DashboardWarningLocation(ContinuityWarning warning)
{
if (warning.SceneNumber.HasValue)
{
return string.IsNullOrWhiteSpace(warning.SceneTitle)
? $"Scene {warning.SceneNumber.Value:g}"
: $"Scene {warning.SceneNumber.Value:g}: {warning.SceneTitle}";
}
if (warning.ChapterNumber.HasValue)
{
return string.IsNullOrWhiteSpace(warning.ChapterTitle)
? $"Chapter {warning.ChapterNumber.Value:g}"
: $"Chapter {warning.ChapterNumber.Value:g}: {warning.ChapterTitle}";
}
return warning.BookTitle ?? warning.EntityType;
}
private static string RelativeTimeLabel(DateTime createdDateUtc)
{
var elapsed = DateTime.UtcNow - DateTime.SpecifyKind(createdDateUtc, DateTimeKind.Utc);
if (elapsed.TotalMinutes < 1)
{
return "Just now";
}
if (elapsed.TotalMinutes < 60)
{
var minutes = Math.Max(1, (int)Math.Floor(elapsed.TotalMinutes));
return minutes == 1 ? "1 minute ago" : $"{minutes} minutes ago";
}
if (elapsed.TotalHours < 24)
{
var hours = Math.Max(1, (int)Math.Floor(elapsed.TotalHours));
return hours == 1 ? "1 hour ago" : $"{hours} hours ago";
}
if (elapsed.TotalDays < 7)
{
var days = Math.Max(1, (int)Math.Floor(elapsed.TotalDays));
return days == 1 ? "Yesterday" : $"{days} days ago";
}
return createdDateUtc.ToString("dd MMM yyyy");
}
public Task<Project?> GetArchivedOwnerProjectAsync(int projectId) public Task<Project?> GetArchivedOwnerProjectAsync(int projectId)
{ {
return currentUser.UserId.HasValue return currentUser.UserId.HasValue

View File

@ -31,6 +31,8 @@ public sealed class ProjectDetailViewModel
{ {
public Project Project { get; set; } = new(); public Project Project { get; set; } = new();
public ProjectDashboardSummaryViewModel Summary { get; set; } = new(); public ProjectDashboardSummaryViewModel Summary { get; set; } = new();
public IReadOnlyList<ProjectDashboardActivityItemViewModel> RecentActivity { get; set; } = [];
public ProjectDashboardAttentionViewModel NeedsAttention { get; set; } = new();
public IReadOnlyList<ProjectDashboardBookCardViewModel> Books { get; set; } = []; public IReadOnlyList<ProjectDashboardBookCardViewModel> Books { get; set; } = [];
} }
@ -67,6 +69,52 @@ public sealed class ProjectDashboardBookCardViewModel
: $"{CurrentWordCount:N0} words"; : $"{CurrentWordCount:N0} words";
} }
public sealed class ProjectDashboardActivityItemViewModel
{
public string ActivityType { get; set; } = string.Empty;
public string EntityType { get; set; } = string.Empty;
public string? EntityName { get; set; }
public string? Description { get; set; }
public string UserLabel { get; set; } = string.Empty;
public string TimeLabel { get; set; } = string.Empty;
public string IconLabel { get; set; } = string.Empty;
}
public sealed class ProjectDashboardAttentionViewModel
{
public IReadOnlyList<ProjectDashboardWarningItemViewModel> Warnings { get; set; } = [];
public IReadOnlyList<ProjectDashboardThreadItemViewModel> Threads { get; set; } = [];
public IReadOnlyList<ProjectDashboardOverdueBookViewModel> OverdueBooks { get; set; } = [];
public bool ShowMissingSchedulePrompt { get; set; }
public bool HasItems => Warnings.Any() || Threads.Any() || OverdueBooks.Any() || ShowMissingSchedulePrompt;
}
public sealed class ProjectDashboardWarningItemViewModel
{
public string SeverityName { get; set; } = string.Empty;
public string WarningTypeName { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string? LocationLabel { get; set; }
}
public sealed class ProjectDashboardThreadItemViewModel
{
public int PlotThreadID { get; set; }
public string ThreadTitle { get; set; } = string.Empty;
public string PlotLineName { get; set; } = string.Empty;
public string ThreadStatusName { get; set; } = string.Empty;
public int Importance { get; set; }
public string AttentionLabel { get; set; } = string.Empty;
}
public sealed class ProjectDashboardOverdueBookViewModel
{
public int BookID { get; set; }
public string BookTitle { get; set; } = string.Empty;
public DateTime TargetCompletionDate { get; set; }
}
public sealed class ProjectCollaboratorsViewModel public sealed class ProjectCollaboratorsViewModel
{ {
public Project Project { get; set; } = new(); public Project Project { get; set; } = new();

View File

@ -86,6 +86,141 @@
</div> </div>
</section> </section>
<section class="mb-4" aria-label="Dashboard attention">
<div class="row g-4">
<div class="col-12 col-xl-7">
<article class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3">
<h2 class="h5 mb-0">Recent Activity</h2>
<a class="btn btn-outline-secondary btn-sm" asp-action="Activity" asp-route-id="@Model.Project.ProjectID">View all</a>
</div>
@if (!Model.RecentActivity.Any())
{
<p class="muted mb-0">No project activity has been recorded yet.</p>
}
else
{
<div class="list-group list-group-flush">
@foreach (var item in Model.RecentActivity)
{
<div class="list-group-item px-0">
<div class="d-flex gap-3 align-items-start">
<span class="status-pill flex-shrink-0">@item.IconLabel</span>
<div class="flex-grow-1">
<div class="d-flex align-items-start justify-content-between gap-2">
<strong>@item.ActivityType @item.EntityType</strong>
<span class="small text-muted">@item.TimeLabel</span>
</div>
@if (!string.IsNullOrWhiteSpace(item.EntityName))
{
<p class="mb-1">@item.EntityName</p>
}
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="muted mb-1">@item.Description</p>
}
<p class="small text-muted mb-0">@item.UserLabel</p>
</div>
</div>
</div>
}
</div>
}
</div>
</article>
</div>
<div class="col-12 col-xl-5">
<article class="card h-100">
<div class="card-body">
<h2 class="h5 mb-3">Needs Attention</h2>
@if (!Model.NeedsAttention.HasItems)
{
<p class="muted mb-0">Nothing needs attention right now.</p>
}
else
{
@if (Model.NeedsAttention.Warnings.Any())
{
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
<h3 class="h6 mb-0">Continuity warnings</h3>
<a class="small" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Review</a>
</div>
@foreach (var warning in Model.NeedsAttention.Warnings)
{
<div class="border-top py-2">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="status-pill">@warning.SeverityName</span>
<strong class="small">@warning.WarningTypeName</strong>
</div>
<p class="mb-1">@warning.Message</p>
@if (!string.IsNullOrWhiteSpace(warning.LocationLabel))
{
<p class="small text-muted mb-0">@warning.LocationLabel</p>
}
</div>
}
</div>
}
@if (Model.NeedsAttention.Threads.Any())
{
<div class="mb-3">
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
<h3 class="h6 mb-0">Plot threads</h3>
<a class="small" asp-controller="PlotThreads" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Review</a>
</div>
@foreach (var thread in Model.NeedsAttention.Threads)
{
<div class="border-top py-2">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="status-pill">@thread.AttentionLabel</span>
<span class="small text-muted">Importance @thread.Importance</span>
</div>
<strong>@thread.ThreadTitle</strong>
<p class="small text-muted mb-0">@thread.PlotLineName / @thread.ThreadStatusName</p>
</div>
}
</div>
}
@if (Model.NeedsAttention.ShowMissingSchedulePrompt)
{
<div class="border-top py-2">
<div class="d-flex align-items-start justify-content-between gap-3">
<div>
<strong>No active writing schedule</strong>
<p class="small text-muted mb-0">Create a schedule when you are ready to plan writing sessions.</p>
</div>
<a class="btn btn-outline-secondary btn-sm" asp-controller="Writer" asp-action="ScheduleSetup" asp-route-projectId="@Model.Project.ProjectID">Set up</a>
</div>
</div>
}
@if (Model.NeedsAttention.OverdueBooks.Any())
{
<div class="mt-3">
<h3 class="h6 mb-2">Overdue target dates</h3>
@foreach (var book in Model.NeedsAttention.OverdueBooks)
{
<div class="border-top py-2">
<a asp-controller="Books" asp-action="Details" asp-route-id="@book.BookID">@book.BookTitle</a>
<p class="small text-muted mb-0">Target completion was @book.TargetCompletionDate.ToString("dd MMM yyyy")</p>
</div>
}
</div>
}
}
</div>
</article>
</div>
</div>
</section>
<section aria-label="Books"> <section aria-label="Books">
<div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3"> <div class="d-flex align-items-center justify-content-between gap-3 flex-wrap mb-3">
<h2 class="h4 mb-0">Books <help-icon key="projects.books" /></h2> <h2 class="h4 mb-0">Books <help-icon key="projects.books" /></h2>