Implemented the dashboard refinements without database or stored procedure changes.

This commit is contained in:
Nick Beckley 2026-06-15 16:29:12 +01:00
parent 76fde1b74b
commit 22ba3b3b9a
5 changed files with 318 additions and 22 deletions

View File

@ -426,6 +426,7 @@ public sealed class ProjectService(
ActivePlotThreadCount = metrics.ActivePlotThreadCount,
ContinuityWarningCount = metrics.ContinuityWarningCount
},
SeriesProgress = BuildSeriesProgress(projectBooks),
RecentActivity = recentActivity.Select(ToDashboardActivityItem).ToList(),
NeedsAttention = new ProjectDashboardAttentionViewModel
{
@ -443,6 +444,7 @@ public sealed class ProjectService(
TargetCompletionDate = book.TargetCompletionDate!.Value
})
.ToList(),
BookItems = BuildBookAttentionItems(projectBooks),
ShowMissingSchedulePrompt = !writingPlans.Any(x => x.IsActive)
},
Books = projectBooks.Select(book => new ProjectDashboardBookCardViewModel
@ -452,6 +454,7 @@ public sealed class ProjectService(
BookTitle = book.BookTitle,
Subtitle = book.Subtitle,
Status = book.Status,
StatusCssClass = DashboardStatusCssClass(book.Status),
CurrentWordCount = book.CurrentWordCount,
TargetWordCount = book.TargetWordCount,
ProgressPercentage = book.ProgressPercentage,
@ -463,6 +466,80 @@ public sealed class ProjectService(
};
}
private static ProjectDashboardSeriesProgressViewModel BuildSeriesProgress(IReadOnlyList<Book> books)
{
var published = books.Count(book => string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase));
var drafting = books.Count(book => string.Equals(book.Status, BookStatuses.Drafting, StringComparison.OrdinalIgnoreCase));
var planning = books.Count(book => string.Equals(book.Status, BookStatuses.Planning, StringComparison.OrdinalIgnoreCase));
var targetWordTotal = books.Where(book => book.TargetWordCount is > 0).Sum(book => book.TargetWordCount!.Value);
if (targetWordTotal > 0)
{
var currentWordTotal = books.Where(book => book.TargetWordCount is > 0).Sum(book => book.CurrentWordCount);
return new ProjectDashboardSeriesProgressViewModel
{
Percentage = Math.Min(100m, Math.Round(currentWordTotal * 100m / targetWordTotal, 1)),
SupportingLine = $"{published:N0} published • {drafting:N0} drafting • {planning:N0} planning",
CalculationLabel = "Based on target word counts"
};
}
// Fallback keeps projects without target word counts feeling active without inventing new story logic.
var statusProgress = books.Count == 0
? 0m
: books.Average(book => DashboardStatusProgressWeight(book.Status));
return new ProjectDashboardSeriesProgressViewModel
{
Percentage = Math.Round(statusProgress, 1),
SupportingLine = $"{published:N0} published • {drafting:N0} drafting • {planning:N0} planning",
CalculationLabel = "Based on book statuses"
};
}
private static IReadOnlyList<ProjectDashboardBookAttentionItemViewModel> BuildBookAttentionItems(IReadOnlyList<Book> books)
{
return books
.Where(book => !string.Equals(book.Status, BookStatuses.Published, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(book.Status, BookStatuses.Archived, StringComparison.OrdinalIgnoreCase))
.SelectMany(book => DashboardBookAttentionItems(book))
.Take(5)
.ToList();
}
private static IEnumerable<ProjectDashboardBookAttentionItemViewModel> DashboardBookAttentionItems(Book book)
{
if (book.SceneCount == 0)
{
yield return new ProjectDashboardBookAttentionItemViewModel
{
BookID = book.BookID,
BookTitle = book.BookTitle,
Message = "No scenes have been added yet."
};
}
if (book.TargetWordCount is null or <= 0)
{
yield return new ProjectDashboardBookAttentionItemViewModel
{
BookID = book.BookID,
BookTitle = book.BookTitle,
Message = "No target word count is set."
};
}
if (book.UpdatedDate.Date <= DateTime.Today.AddDays(-90))
{
yield return new ProjectDashboardBookAttentionItemViewModel
{
BookID = book.BookID,
BookTitle = book.BookTitle,
Message = $"Not modified since {book.UpdatedDate:dd MMM yyyy}."
};
}
}
private static ProjectDashboardActivityItemViewModel ToDashboardActivityItem(ProjectActivity item)
{
return new ProjectDashboardActivityItemViewModel
@ -511,6 +588,42 @@ public sealed class ProjectService(
&& !string.Equals(book.Status, BookStatuses.Archived, StringComparison.OrdinalIgnoreCase);
}
private static decimal DashboardStatusProgressWeight(string status)
{
return status switch
{
BookStatuses.Published or BookStatuses.ReadyForPublication => 100m,
BookStatuses.Proofreading => 90m,
BookStatuses.Editing => 80m,
BookStatuses.Revising => 70m,
BookStatuses.Drafting => 45m,
BookStatuses.Paused => 35m,
BookStatuses.Outlining => 20m,
BookStatuses.Research => 15m,
BookStatuses.Planning => 10m,
_ => 0m
};
}
private static string DashboardStatusCssClass(string status)
{
return status switch
{
BookStatuses.Published => "dashboard-status-published",
BookStatuses.Drafting => "dashboard-status-drafting",
BookStatuses.Planning => "dashboard-status-planning",
BookStatuses.Research => "dashboard-status-research",
BookStatuses.Outlining => "dashboard-status-outlining",
BookStatuses.Revising => "dashboard-status-revising",
BookStatuses.Editing => "dashboard-status-editing",
BookStatuses.Proofreading => "dashboard-status-proofreading",
BookStatuses.ReadyForPublication => "dashboard-status-ready",
BookStatuses.Paused => "dashboard-status-paused",
BookStatuses.Archived => "dashboard-status-archived",
_ => "dashboard-status-planning"
};
}
private static string DashboardIconLabel(string entityType, string activityType)
{
var source = string.IsNullOrWhiteSpace(entityType) ? activityType : entityType;

View File

@ -31,6 +31,7 @@ public sealed class ProjectDetailViewModel
{
public Project Project { get; set; } = new();
public ProjectDashboardSummaryViewModel Summary { get; set; } = new();
public ProjectDashboardSeriesProgressViewModel SeriesProgress { get; set; } = new();
public IReadOnlyList<ProjectDashboardActivityItemViewModel> RecentActivity { get; set; } = [];
public ProjectDashboardAttentionViewModel NeedsAttention { get; set; } = new();
public IReadOnlyList<ProjectDashboardBookCardViewModel> Books { get; set; } = [];
@ -56,6 +57,7 @@ public sealed class ProjectDashboardBookCardViewModel
public string BookTitle { get; set; } = string.Empty;
public string? Subtitle { get; set; }
public string Status { get; set; } = BookStatuses.Planning;
public string StatusCssClass { get; set; } = "dashboard-status-planning";
public int CurrentWordCount { get; set; }
public int? TargetWordCount { get; set; }
public decimal? ProgressPercentage { get; set; }
@ -69,6 +71,13 @@ public sealed class ProjectDashboardBookCardViewModel
: $"{CurrentWordCount:N0} words";
}
public sealed class ProjectDashboardSeriesProgressViewModel
{
public decimal Percentage { get; set; }
public string SupportingLine { get; set; } = string.Empty;
public string CalculationLabel { get; set; } = string.Empty;
}
public sealed class ProjectDashboardActivityItemViewModel
{
public string ActivityType { get; set; } = string.Empty;
@ -85,9 +94,10 @@ public sealed class ProjectDashboardAttentionViewModel
public IReadOnlyList<ProjectDashboardWarningItemViewModel> Warnings { get; set; } = [];
public IReadOnlyList<ProjectDashboardThreadItemViewModel> Threads { get; set; } = [];
public IReadOnlyList<ProjectDashboardOverdueBookViewModel> OverdueBooks { get; set; } = [];
public IReadOnlyList<ProjectDashboardBookAttentionItemViewModel> BookItems { get; set; } = [];
public bool ShowMissingSchedulePrompt { get; set; }
public bool HasItems => Warnings.Any() || Threads.Any() || OverdueBooks.Any() || ShowMissingSchedulePrompt;
public bool HasItems => Warnings.Any() || Threads.Any() || OverdueBooks.Any() || BookItems.Any() || ShowMissingSchedulePrompt;
}
public sealed class ProjectDashboardWarningItemViewModel
@ -115,6 +125,14 @@ public sealed class ProjectDashboardOverdueBookViewModel
public DateTime TargetCompletionDate { get; set; }
}
public sealed class ProjectDashboardBookAttentionItemViewModel
{
public int BookID { get; set; }
public string BookTitle { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Label { get; set; } = "Worth a look";
}
public sealed class ProjectCollaboratorsViewModel
{
public Project Project { get; set; } = new();

View File

@ -5,15 +5,15 @@
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"))
("Total Books", Model.Summary.TotalBooks.ToString("N0"), string.Empty),
("Published Books", Model.Summary.PublishedBooks.ToString("N0"), string.Empty),
("Drafting Books", Model.Summary.DraftingBooks.ToString("N0"), string.Empty),
("Planning Books", Model.Summary.PlanningBooks.ToString("N0"), string.Empty),
("Total Words", Model.Summary.TotalWords.ToString("N0"), string.Empty),
("Characters", Model.Summary.CharacterCount.ToString("N0"), string.Empty),
("Locations", Model.Summary.LocationCount.ToString("N0"), string.Empty),
("Active Plot Threads", Model.Summary.ActivePlotThreadCount.ToString("N0"), string.Empty),
("Issues to Review", Model.Summary.ContinuityWarningCount.ToString("N0"), Model.Summary.ContinuityWarningCount == 0 ? "dashboard-summary-calm" : Model.Summary.ContinuityWarningCount <= 10 ? "dashboard-summary-mild" : "dashboard-summary-attention")
};
}
@ -75,7 +75,7 @@
@foreach (var card in summaryCards)
{
<div class="col-6 col-md-4 col-xl-2">
<div class="card h-100">
<div class="card h-100 dashboard-summary-card @card.Item3">
<div class="card-body py-3">
<p class="eyebrow mb-2">@card.Item1</p>
<strong class="fs-3 lh-1">@card.Item2</strong>
@ -86,6 +86,24 @@
</div>
</section>
<section class="mb-4" aria-label="Series progress">
<div class="card dashboard-progress-card">
<div class="card-body">
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-3">
<div>
<p class="eyebrow mb-1">Series Progress</p>
<h2 class="h5 mb-1">@Model.SeriesProgress.Percentage.ToString("0.#")% complete</h2>
<p class="muted mb-0">@Model.SeriesProgress.SupportingLine</p>
</div>
<span class="status-pill dashboard-progress-source">@Model.SeriesProgress.CalculationLabel</span>
</div>
<div class="progress dashboard-series-progress" role="progressbar" aria-label="Series progress" aria-valuenow="@Math.Round(Model.SeriesProgress.Percentage)" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" style="width:@Model.SeriesProgress.Percentage.ToString("0.#")%"></div>
</div>
</div>
</div>
</section>
<section class="mb-4" 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>
@ -118,15 +136,17 @@
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
<div>
<p class="eyebrow mb-1">Book @book.BookNumber</p>
<h3 class="h5 mb-1">
<a asp-controller="Books" asp-action="Details" asp-route-id="@book.BookID">@book.BookTitle</a>
</h3>
@if (!string.IsNullOrWhiteSpace(book.Subtitle))
{
<p class="muted mb-0">@book.Subtitle</p>
}
<div class="dashboard-book-title-block">
<h3 class="h5 mb-1">
<a asp-controller="Books" asp-action="Details" asp-route-id="@book.BookID">@book.BookTitle</a>
</h3>
@if (!string.IsNullOrWhiteSpace(book.Subtitle))
{
<p class="dashboard-book-subtitle mb-0">@book.Subtitle</p>
}
</div>
</div>
<span class="status-pill">@book.Status</span>
<span class="status-pill dashboard-status-pill @book.StatusCssClass">@book.Status</span>
</div>
<p class="mb-2">@book.WordCountLabel</p>
@ -212,11 +232,11 @@
<div class="col-12 col-xl-5">
<article class="card h-100">
<div class="card-body">
<h2 class="h5 mb-3">Needs Attention</h2>
<h2 class="h5 mb-3">Worth a look</h2>
@if (!Model.NeedsAttention.HasItems)
{
<p class="muted mb-0">Nothing needs attention right now.</p>
<p class="muted mb-0">Nothing urgent. This project is in good shape.</p>
}
else
{
@ -265,6 +285,23 @@
</div>
}
@if (Model.NeedsAttention.BookItems.Any())
{
<div class="mb-3">
<h3 class="h6 mb-2">Book setup</h3>
@foreach (var item in Model.NeedsAttention.BookItems)
{
<div class="border-top py-2">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="status-pill dashboard-attention-soft">@item.Label</span>
</div>
<a asp-controller="Books" asp-action="Details" asp-route-id="@item.BookID">@item.BookTitle</a>
<p class="small text-muted mb-0">@item.Message</p>
</div>
}
</div>
}
@if (Model.NeedsAttention.ShowMissingSchedulePrompt)
{
<div class="border-top py-2">

View File

@ -360,6 +360,133 @@ td.text-end {
white-space: nowrap;
}
.dashboard-summary-card {
border-color: var(--plotline-line);
}
.dashboard-summary-calm {
border-color: rgba(47, 111, 99, 0.28);
background: rgba(238, 245, 242, 0.62);
}
.dashboard-summary-mild {
border-color: rgba(197, 139, 43, 0.32);
background: rgba(255, 248, 232, 0.68);
}
.dashboard-summary-attention {
border-color: rgba(142, 80, 103, 0.32);
background: rgba(252, 241, 246, 0.7);
}
.dashboard-progress-card {
border-color: rgba(47, 111, 99, 0.18);
background: linear-gradient(180deg, #ffffff 0%, rgba(238, 245, 242, 0.64) 100%);
}
.dashboard-series-progress {
height: 10px;
background: rgba(47, 111, 99, 0.1);
}
.dashboard-series-progress .progress-bar {
background: linear-gradient(90deg, var(--plotline-accent), #6fa99b);
}
.dashboard-progress-source {
border-color: rgba(47, 111, 99, 0.2);
color: var(--plotline-accent-dark);
background: rgba(238, 245, 242, 0.9);
}
.dashboard-book-title-block h3 {
line-height: 1.16;
}
.dashboard-book-title-block a {
color: var(--plotline-ink);
text-decoration-color: rgba(47, 111, 99, 0.35);
}
.dashboard-book-subtitle {
color: var(--plotline-muted);
font-size: 0.95rem;
line-height: 1.25;
}
.dashboard-status-pill {
border-color: var(--dashboard-status-border, #c8d8d2);
color: var(--dashboard-status-ink, var(--plotline-accent-dark));
background: var(--dashboard-status-bg, var(--plotline-soft));
}
.dashboard-status-published {
--dashboard-status-border: rgba(47, 111, 99, 0.32);
--dashboard-status-bg: #e7f2ed;
--dashboard-status-ink: #22534a;
}
.dashboard-status-drafting {
--dashboard-status-border: rgba(184, 118, 43, 0.35);
--dashboard-status-bg: #fff2dd;
--dashboard-status-ink: #7b4b12;
}
.dashboard-status-planning {
--dashboard-status-border: rgba(51, 117, 155, 0.3);
--dashboard-status-bg: #e9f3f8;
--dashboard-status-ink: #245b78;
}
.dashboard-status-research,
.dashboard-status-outlining {
--dashboard-status-border: rgba(103, 80, 164, 0.26);
--dashboard-status-bg: #f0edf8;
--dashboard-status-ink: #51417c;
}
.dashboard-status-revising {
--dashboard-status-border: rgba(123, 82, 148, 0.3);
--dashboard-status-bg: #f4eefa;
--dashboard-status-ink: #5f3e73;
}
.dashboard-status-editing {
--dashboard-status-border: rgba(68, 75, 130, 0.3);
--dashboard-status-bg: #eceef8;
--dashboard-status-ink: #343b72;
}
.dashboard-status-proofreading {
--dashboard-status-border: rgba(43, 130, 123, 0.3);
--dashboard-status-bg: #e6f5f2;
--dashboard-status-ink: #23625d;
}
.dashboard-status-ready {
--dashboard-status-border: rgba(185, 142, 30, 0.34);
--dashboard-status-bg: #fff6d8;
--dashboard-status-ink: #765a0f;
}
.dashboard-status-paused {
--dashboard-status-border: rgba(102, 113, 123, 0.28);
--dashboard-status-bg: #f0f2f1;
--dashboard-status-ink: #56616b;
}
.dashboard-status-archived {
--dashboard-status-border: rgba(139, 80, 86, 0.28);
--dashboard-status-bg: #f3eded;
--dashboard-status-ink: #76474d;
}
.dashboard-attention-soft {
border-color: rgba(102, 113, 123, 0.24);
color: #56616b;
background: #f3f4f2;
}
.order-buttons {
display: flex;
gap: 6px;

File diff suppressed because one or more lines are too long