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?> GetArchivedForOwnerAsync(int projectId, int userId);
Task<ProjectDashboardMetrics> GetDashboardMetricsAsync(int projectId);
Task<IReadOnlyList<ProjectDashboardAttentionThread>> ListDashboardAttentionThreadsAsync(int projectId, int count);
Task<IReadOnlyList<GenreMetricPreset>> ListGenreMetricPresetsAsync();
Task<int> SaveAsync(Project project);
Task<int> SaveAsync(Project project, int userId, string? genreMetricPresetKey);
@ -43,6 +44,7 @@ public interface IProjectCollaborationRepository
public interface IProjectActivityRepository
{
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);
}
@ -218,6 +220,7 @@ public interface IWarningRepository
Task<ContinuityValidationSummary> ValidateBookAsync(int bookId);
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>> ListDashboardUnresolvedAsync(int projectId, int count);
Task<IReadOnlyList<ContinuityWarning>> ListBySceneAsync(int sceneId);
Task<IReadOnlyList<SceneWarningCount>> GetSceneCountsAsync(int projectId, int? bookId);
Task DismissAsync(int continuityWarningId);
@ -578,6 +581,50 @@ public sealed class WarningRepository(ISqlConnectionFactory connectionFactory) :
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)
{
using var connection = connectionFactory.CreateConnection();
@ -1859,6 +1906,40 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
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()
{
using var connection = connectionFactory.CreateConnection();
@ -2034,6 +2115,32 @@ public sealed class ProjectActivityRepository(ISqlConnectionFactory connectionFa
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)
{
using var connection = connectionFactory.CreateConnection();

View File

@ -75,6 +75,18 @@ public sealed class ProjectDashboardMetrics
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 bool Succeeded { get; init; }

View File

@ -368,6 +368,9 @@ public interface IAcceptanceService
public sealed class ProjectService(
IProjectRepository projects,
IBookRepository books,
IProjectActivityRepository projectActivity,
IWarningRepository warnings,
IWritingScheduleRepository writingSchedule,
IProjectActivityService activity,
ICurrentUserService currentUser,
IHardDeleteFileCleanupService fileCleanup,
@ -389,7 +392,13 @@ public sealed class ProjectService(
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)
{
return null;
@ -397,6 +406,10 @@ public sealed class ProjectService(
var projectBooks = await books.ListByProjectAsync(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
{
@ -413,6 +426,25 @@ public sealed class ProjectService(
ActivePlotThreadCount = metrics.ActivePlotThreadCount,
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
{
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)
{
return currentUser.UserId.HasValue

View File

@ -31,6 +31,8 @@ public sealed class ProjectDetailViewModel
{
public Project Project { 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; } = [];
}
@ -67,6 +69,52 @@ public sealed class ProjectDashboardBookCardViewModel
: $"{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 Project Project { get; set; } = new();

View File

@ -86,6 +86,141 @@
</div>
</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">
<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>