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, ActivePlotThreadCount = metrics.ActivePlotThreadCount,
ContinuityWarningCount = metrics.ContinuityWarningCount ContinuityWarningCount = metrics.ContinuityWarningCount
}, },
SeriesProgress = BuildSeriesProgress(projectBooks),
RecentActivity = recentActivity.Select(ToDashboardActivityItem).ToList(), RecentActivity = recentActivity.Select(ToDashboardActivityItem).ToList(),
NeedsAttention = new ProjectDashboardAttentionViewModel NeedsAttention = new ProjectDashboardAttentionViewModel
{ {
@ -443,6 +444,7 @@ public sealed class ProjectService(
TargetCompletionDate = book.TargetCompletionDate!.Value TargetCompletionDate = book.TargetCompletionDate!.Value
}) })
.ToList(), .ToList(),
BookItems = BuildBookAttentionItems(projectBooks),
ShowMissingSchedulePrompt = !writingPlans.Any(x => x.IsActive) ShowMissingSchedulePrompt = !writingPlans.Any(x => x.IsActive)
}, },
Books = projectBooks.Select(book => new ProjectDashboardBookCardViewModel Books = projectBooks.Select(book => new ProjectDashboardBookCardViewModel
@ -452,6 +454,7 @@ public sealed class ProjectService(
BookTitle = book.BookTitle, BookTitle = book.BookTitle,
Subtitle = book.Subtitle, Subtitle = book.Subtitle,
Status = book.Status, Status = book.Status,
StatusCssClass = DashboardStatusCssClass(book.Status),
CurrentWordCount = book.CurrentWordCount, CurrentWordCount = book.CurrentWordCount,
TargetWordCount = book.TargetWordCount, TargetWordCount = book.TargetWordCount,
ProgressPercentage = book.ProgressPercentage, 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) private static ProjectDashboardActivityItemViewModel ToDashboardActivityItem(ProjectActivity item)
{ {
return new ProjectDashboardActivityItemViewModel return new ProjectDashboardActivityItemViewModel
@ -511,6 +588,42 @@ public sealed class ProjectService(
&& !string.Equals(book.Status, BookStatuses.Archived, StringComparison.OrdinalIgnoreCase); && !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) private static string DashboardIconLabel(string entityType, string activityType)
{ {
var source = string.IsNullOrWhiteSpace(entityType) ? activityType : entityType; var source = string.IsNullOrWhiteSpace(entityType) ? activityType : entityType;

View File

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

View File

@ -5,15 +5,15 @@
var summaryCards = new[] var summaryCards = new[]
{ {
("Total Books", Model.Summary.TotalBooks.ToString("N0")), ("Total Books", Model.Summary.TotalBooks.ToString("N0"), string.Empty),
("Published Books", Model.Summary.PublishedBooks.ToString("N0")), ("Published Books", Model.Summary.PublishedBooks.ToString("N0"), string.Empty),
("Drafting Books", Model.Summary.DraftingBooks.ToString("N0")), ("Drafting Books", Model.Summary.DraftingBooks.ToString("N0"), string.Empty),
("Planning Books", Model.Summary.PlanningBooks.ToString("N0")), ("Planning Books", Model.Summary.PlanningBooks.ToString("N0"), string.Empty),
("Total Words", Model.Summary.TotalWords.ToString("N0")), ("Total Words", Model.Summary.TotalWords.ToString("N0"), string.Empty),
("Characters", Model.Summary.CharacterCount.ToString("N0")), ("Characters", Model.Summary.CharacterCount.ToString("N0"), string.Empty),
("Locations", Model.Summary.LocationCount.ToString("N0")), ("Locations", Model.Summary.LocationCount.ToString("N0"), string.Empty),
("Active Plot Threads", Model.Summary.ActivePlotThreadCount.ToString("N0")), ("Active Plot Threads", Model.Summary.ActivePlotThreadCount.ToString("N0"), string.Empty),
("Continuity Warnings", Model.Summary.ContinuityWarningCount.ToString("N0")) ("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) @foreach (var card in summaryCards)
{ {
<div class="col-6 col-md-4 col-xl-2"> <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"> <div class="card-body py-3">
<p class="eyebrow mb-2">@card.Item1</p> <p class="eyebrow mb-2">@card.Item1</p>
<strong class="fs-3 lh-1">@card.Item2</strong> <strong class="fs-3 lh-1">@card.Item2</strong>
@ -86,6 +86,24 @@
</div> </div>
</section> </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"> <section class="mb-4" 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>
@ -118,15 +136,17 @@
<div class="d-flex align-items-start justify-content-between gap-2 mb-2"> <div class="d-flex align-items-start justify-content-between gap-2 mb-2">
<div> <div>
<p class="eyebrow mb-1">Book @book.BookNumber</p> <p class="eyebrow mb-1">Book @book.BookNumber</p>
<h3 class="h5 mb-1"> <div class="dashboard-book-title-block">
<a asp-controller="Books" asp-action="Details" asp-route-id="@book.BookID">@book.BookTitle</a> <h3 class="h5 mb-1">
</h3> <a asp-controller="Books" asp-action="Details" asp-route-id="@book.BookID">@book.BookTitle</a>
@if (!string.IsNullOrWhiteSpace(book.Subtitle)) </h3>
{ @if (!string.IsNullOrWhiteSpace(book.Subtitle))
<p class="muted mb-0">@book.Subtitle</p> {
} <p class="dashboard-book-subtitle mb-0">@book.Subtitle</p>
}
</div>
</div> </div>
<span class="status-pill">@book.Status</span> <span class="status-pill dashboard-status-pill @book.StatusCssClass">@book.Status</span>
</div> </div>
<p class="mb-2">@book.WordCountLabel</p> <p class="mb-2">@book.WordCountLabel</p>
@ -212,11 +232,11 @@
<div class="col-12 col-xl-5"> <div class="col-12 col-xl-5">
<article class="card h-100"> <article class="card h-100">
<div class="card-body"> <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) @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 else
{ {
@ -265,6 +285,23 @@
</div> </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) @if (Model.NeedsAttention.ShowMissingSchedulePrompt)
{ {
<div class="border-top py-2"> <div class="border-top py-2">

View File

@ -360,6 +360,133 @@ td.text-end {
white-space: nowrap; 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 { .order-buttons {
display: flex; display: flex;
gap: 6px; gap: 6px;

File diff suppressed because one or more lines are too long