Implemented the dashboard refinements without database or stored procedure changes.
This commit is contained in:
parent
76fde1b74b
commit
22ba3b3b9a
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
<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="muted mb-0">@book.Subtitle</p>
|
||||
<p class="dashboard-book-subtitle mb-0">@book.Subtitle</p>
|
||||
}
|
||||
</div>
|
||||
<span class="status-pill">@book.Status</span>
|
||||
</div>
|
||||
<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">
|
||||
|
||||
@ -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;
|
||||
|
||||
3
PlotLine/wwwroot/css/site.min.css
vendored
3
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user