diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 7fc5f80..ca23727 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -15,6 +15,7 @@ public interface IProjectRepository Task GetForUserAsync(int projectId, int userId); Task GetArchivedForOwnerAsync(int projectId, int userId); Task GetDashboardMetricsAsync(int projectId); + Task> ListDashboardAttentionThreadsAsync(int projectId, int count); Task> ListGenreMetricPresetsAsync(); Task SaveAsync(Project project); Task SaveAsync(Project project, int userId, string? genreMetricPresetKey); @@ -43,6 +44,7 @@ public interface IProjectCollaborationRepository public interface IProjectActivityRepository { Task> ListForProjectAsync(int projectId); + Task> ListRecentForProjectAsync(int projectId, int count); Task RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null); } @@ -218,6 +220,7 @@ public interface IWarningRepository Task ValidateBookAsync(int bookId); Task ValidateSceneAsync(int sceneId); Task> ListAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional); + Task> ListDashboardUnresolvedAsync(int projectId, int count); Task> ListBySceneAsync(int sceneId); Task> 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> ListDashboardUnresolvedAsync(int projectId, int count) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + 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> ListBySceneAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); @@ -1859,6 +1906,40 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) : new { ProjectID = projectId }); } + public async Task> ListDashboardAttentionThreadsAsync(int projectId, int count) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + 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> ListGenreMetricPresetsAsync() { using var connection = connectionFactory.CreateConnection(); @@ -2034,6 +2115,32 @@ public sealed class ProjectActivityRepository(ISqlConnectionFactory connectionFa return rows.ToList(); } + public async Task> ListRecentForProjectAsync(int projectId, int count) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + """ + 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 RecordAsync(int projectId, int? userId, string activityType, string entityType, int? entityId, string? entityName, string? description = null) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 6906a9d..24c8570 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -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; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index d570e10..65a5790 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -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 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 GetArchivedOwnerProjectAsync(int projectId) { return currentUser.UserId.HasValue diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index a1dccdc..3ca1822 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -31,6 +31,8 @@ public sealed class ProjectDetailViewModel { public Project Project { get; set; } = new(); public ProjectDashboardSummaryViewModel Summary { get; set; } = new(); + public IReadOnlyList RecentActivity { get; set; } = []; + public ProjectDashboardAttentionViewModel NeedsAttention { get; set; } = new(); public IReadOnlyList 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 Warnings { get; set; } = []; + public IReadOnlyList Threads { get; set; } = []; + public IReadOnlyList 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(); diff --git a/PlotLine/Views/Projects/Details.cshtml b/PlotLine/Views/Projects/Details.cshtml index d25454e..86fe652 100644 --- a/PlotLine/Views/Projects/Details.cshtml +++ b/PlotLine/Views/Projects/Details.cshtml @@ -86,6 +86,141 @@ +
+
+
+
+
+
+

Recent Activity

+ View all +
+ + @if (!Model.RecentActivity.Any()) + { +

No project activity has been recorded yet.

+ } + else + { +
+ @foreach (var item in Model.RecentActivity) + { +
+
+ @item.IconLabel +
+
+ @item.ActivityType @item.EntityType + @item.TimeLabel +
+ @if (!string.IsNullOrWhiteSpace(item.EntityName)) + { +

@item.EntityName

+ } + @if (!string.IsNullOrWhiteSpace(item.Description)) + { +

@item.Description

+ } +

@item.UserLabel

+
+
+
+ } +
+ } +
+
+
+ +
+
+
+

Needs Attention

+ + @if (!Model.NeedsAttention.HasItems) + { +

Nothing needs attention right now.

+ } + else + { + @if (Model.NeedsAttention.Warnings.Any()) + { +
+
+

Continuity warnings

+ Review +
+ @foreach (var warning in Model.NeedsAttention.Warnings) + { +
+
+ @warning.SeverityName + @warning.WarningTypeName +
+

@warning.Message

+ @if (!string.IsNullOrWhiteSpace(warning.LocationLabel)) + { +

@warning.LocationLabel

+ } +
+ } +
+ } + + @if (Model.NeedsAttention.Threads.Any()) + { +
+
+

Plot threads

+ Review +
+ @foreach (var thread in Model.NeedsAttention.Threads) + { +
+
+ @thread.AttentionLabel + Importance @thread.Importance +
+ @thread.ThreadTitle +

@thread.PlotLineName / @thread.ThreadStatusName

+
+ } +
+ } + + @if (Model.NeedsAttention.ShowMissingSchedulePrompt) + { +
+
+
+ No active writing schedule +

Create a schedule when you are ready to plan writing sessions.

+
+ Set up +
+
+ } + + @if (Model.NeedsAttention.OverdueBooks.Any()) + { +
+

Overdue target dates

+ @foreach (var book in Model.NeedsAttention.OverdueBooks) + { +
+ @book.BookTitle +

Target completion was @book.TargetCompletionDate.ToString("dd MMM yyyy")

+
+ } +
+ } + } +
+
+
+
+
+

Books