diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 8eda1fd..7fc5f80 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -14,6 +14,7 @@ public interface IProjectRepository Task GetAsync(int projectId); Task GetForUserAsync(int projectId, int userId); Task GetArchivedForOwnerAsync(int projectId, int userId); + Task GetDashboardMetricsAsync(int projectId); Task> ListGenreMetricPresetsAsync(); Task SaveAsync(Project project); Task SaveAsync(Project project, int userId, string? genreMetricPresetKey); @@ -1829,6 +1830,35 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) : return await connection.QuerySingleOrDefaultAsync("dbo.Project_GetArchivedForOwner", new { ProjectID = projectId, UserID = userId }, commandType: CommandType.StoredProcedure); } + public async Task GetDashboardMetricsAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + """ + SELECT + (SELECT COUNT(1) FROM dbo.Characters WHERE ProjectID = @ProjectID AND IsArchived = 0) AS CharacterCount, + (SELECT COUNT(1) FROM dbo.Locations WHERE ProjectID = @ProjectID AND IsArchived = 0) AS LocationCount, + ( + SELECT COUNT(1) + 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.IsOpenStatus = 1 + ) AS ActivePlotThreadCount, + ( + SELECT COUNT(1) + FROM dbo.ContinuityWarnings + WHERE ProjectID = @ProjectID + AND IsDismissed = 0 + AND IsIntentional = 0 + ) AS ContinuityWarningCount; + """, + new { ProjectID = projectId }); + } + public async Task> ListGenreMetricPresetsAsync() { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 5605712..6906a9d 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -67,6 +67,14 @@ public sealed class ProjectActivity : "System"; } +public sealed class ProjectDashboardMetrics +{ + public int CharacterCount { get; set; } + public int LocationCount { get; set; } + public int ActivePlotThreadCount { get; set; } + public int ContinuityWarningCount { get; set; } +} + public sealed class ProjectHardDeleteResult { public bool Succeeded { get; init; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index e740727..d570e10 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -395,10 +395,39 @@ public sealed class ProjectService( return null; } + var projectBooks = await books.ListByProjectAsync(projectId); + var metrics = await projects.GetDashboardMetricsAsync(projectId); + return new ProjectDetailViewModel { Project = project, - Books = await books.ListByProjectAsync(projectId) + Summary = new ProjectDashboardSummaryViewModel + { + TotalBooks = projectBooks.Count, + PublishedBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase)), + DraftingBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Drafting, StringComparison.OrdinalIgnoreCase)), + PlanningBooks = projectBooks.Count(book => string.Equals(book.Status, BookStatuses.Planning, StringComparison.OrdinalIgnoreCase)), + TotalWords = projectBooks.Sum(book => book.CurrentWordCount), + CharacterCount = metrics.CharacterCount, + LocationCount = metrics.LocationCount, + ActivePlotThreadCount = metrics.ActivePlotThreadCount, + ContinuityWarningCount = metrics.ContinuityWarningCount + }, + Books = projectBooks.Select(book => new ProjectDashboardBookCardViewModel + { + BookID = book.BookID, + BookNumber = book.BookNumber, + BookTitle = book.BookTitle, + Subtitle = book.Subtitle, + Status = book.Status, + CurrentWordCount = book.CurrentWordCount, + TargetWordCount = book.TargetWordCount, + ProgressPercentage = book.ProgressPercentage, + ChapterCount = book.ChapterCount, + SceneCount = book.SceneCount, + UpdatedDate = book.UpdatedDate, + CoverThumbnailPath = book.CoverThumbnailPath ?? BookCoverService.PlaceholderPath + }).ToList() }; } diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index f5ee55c..a1dccdc 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -30,7 +30,41 @@ public sealed class ProjectIndexViewModel public sealed class ProjectDetailViewModel { public Project Project { get; set; } = new(); - public IReadOnlyList Books { get; set; } = []; + public ProjectDashboardSummaryViewModel Summary { get; set; } = new(); + public IReadOnlyList Books { get; set; } = []; +} + +public sealed class ProjectDashboardSummaryViewModel +{ + public int TotalBooks { get; set; } + public int PublishedBooks { get; set; } + public int DraftingBooks { get; set; } + public int PlanningBooks { get; set; } + public int TotalWords { get; set; } + public int CharacterCount { get; set; } + public int LocationCount { get; set; } + public int ActivePlotThreadCount { get; set; } + public int ContinuityWarningCount { get; set; } +} + +public sealed class ProjectDashboardBookCardViewModel +{ + public int BookID { get; set; } + public int BookNumber { get; set; } + public string BookTitle { get; set; } = string.Empty; + public string? Subtitle { get; set; } + public string Status { get; set; } = BookStatuses.Planning; + public int CurrentWordCount { get; set; } + public int? TargetWordCount { get; set; } + public decimal? ProgressPercentage { get; set; } + public int ChapterCount { get; set; } + public int SceneCount { get; set; } + public DateTime UpdatedDate { get; set; } + public string CoverThumbnailPath { get; set; } = "/images/placeholders/book-cover.svg"; + + public string WordCountLabel => TargetWordCount is > 0 + ? $"{CurrentWordCount:N0} / {TargetWordCount.Value:N0} words" + : $"{CurrentWordCount:N0} words"; } public sealed class ProjectCollaboratorsViewModel diff --git a/PlotLine/Views/Projects/Details.cshtml b/PlotLine/Views/Projects/Details.cshtml index 2b4f620..0442aed 100644 --- a/PlotLine/Views/Projects/Details.cshtml +++ b/PlotLine/Views/Projects/Details.cshtml @@ -2,6 +2,19 @@ @{ ViewData["Title"] = Model.Project.ProjectName; ViewData["ProjectSection"] = "Overview"; + + var summaryCards = new[] + { + ("Total Books", Model.Summary.TotalBooks.ToString("N0")), + ("Published Books", Model.Summary.PublishedBooks.ToString("N0")), + ("Drafting Books", Model.Summary.DraftingBooks.ToString("N0")), + ("Planning Books", Model.Summary.PlanningBooks.ToString("N0")), + ("Total Words", Model.Summary.TotalWords.ToString("N0")), + ("Characters", Model.Summary.CharacterCount.ToString("N0")), + ("Locations", Model.Summary.LocationCount.ToString("N0")), + ("Active Plot Threads", Model.Summary.ActivePlotThreadCount.ToString("N0")), + ("Continuity Warnings", Model.Summary.ContinuityWarningCount.ToString("N0")) + }; }