From 1685acebe3f548b89e671796f0f7e200a8f56824 Mon Sep 17 00:00:00 2001 From: Andrew Roddy Date: Sun, 20 Sep 2026 09:05:43 -0400 Subject: [PATCH 1/2] Updated dashboard --- .../Pages/Financials/NetWorthCalculator.razor | 121 +++++ .../Financials/NetWorthCalculator.razor.css | 146 ++++++ Features/Dashboard/Dashboard.razor | 452 +++++++++++------ Features/Dashboard/Dashboard.razor.css | 480 ++++++++++++++++-- 4 files changed, 1018 insertions(+), 181 deletions(-) diff --git a/Components/Pages/Financials/NetWorthCalculator.razor b/Components/Pages/Financials/NetWorthCalculator.razor index d6cabdf..0f27021 100644 --- a/Components/Pages/Financials/NetWorthCalculator.razor +++ b/Components/Pages/Financials/NetWorthCalculator.razor @@ -1,14 +1,17 @@ @page "/financials" @page "/net-worth" +@attribute [StreamRendering(true)] @rendermode InteractiveServer @using System.ComponentModel.DataAnnotations @using Microsoft.EntityFrameworkCore @using MoneyMirror.Data @using MoneyMirror.Data.Entities @using MoneyMirror.Financial +@using MoneyMirror.HumanCapital @using MoneyMirror.PhysicalAssets @inject MoneyMirrorDbContext DbContext @inject IPhysicalAssetRepository AssetRepository +@inject IMarketPotentialSummaryService MarketPotentialSummaryService Net Worth @@ -60,6 +63,50 @@ + @* + Market potential used to live on the Dashboard, which is now the + landing page. It belongs here, beside the total rather than inside + it: earning power is real but it is not cash, so folding it into + the figure above would make that figure a lie. + *@ +
+
+

Market potential

+ Details → +
+ + @if (IsLoadingPotential) + { +

Estimating...

+ } + else if (PotentialError is not null) + { +

@PotentialError

+ } + else if (!HasProfile) + { +

+ No profile on file yet. Upload a resume to see + what the market pays for what you already know. +

+ } + else if (Compensation is null) + { +

Not available for this profile.

+ } + else + { + + $@Compensation.MinUsd.ToString("N0") – $@Compensation.MaxUsd.ToString("N0") + +

+ AI estimate + across @OccupationCount matched occupation@(OccupationCount == 1 ? "" : "s"). Kept + out of the net worth above on purpose. +

+ } +
+
} + + @* + The Dashboard's inventory panel, moved here: physical capital is a + line in the breakdown above, and this is where you look when you + want to know what is behind that line. Closed by default - the + financial records are what this page is for. + *@ +
+ @* Rendered as a string, not a bool: Blazor omits an attribute whose + value is false, which would drop aria-expanded exactly when the + panel is collapsed and leave the control unannounced. *@ + + @if (ShowInventory) + { + + } +
@if (IsFormOpen) @@ -145,6 +215,13 @@ private bool IsEditing { get; set; } private bool IsLoading { get; set; } = true; private string? ErrorMessage { get; set; } + + private bool IsLoadingPotential { get; set; } = true; + private string? PotentialError { get; set; } + private bool HasProfile { get; set; } + private CompensationEstimate? Compensation { get; set; } + private int OccupationCount { get; set; } + private bool ShowInventory { get; set; } private FinancialRecord Draft { get; set; } = NewDraft(RecordKind.Asset); private FinancialRecord? EditingRecord { get; set; } @@ -175,7 +252,23 @@ private static DateTimeOffset ToDateTimeOffset(DateTime date) => new(DateTime.SpecifyKind(date.Date, DateTimeKind.Unspecified), TimeSpan.Zero); + // The records and the market-potential estimate fail independently: a + // provider outage must never take down the financial records, which are + // the point of the page. They run sequentially rather than via + // Task.WhenAll because both reach the same scoped DbContext (directly, + // and via IPhysicalAssetRepository/IProfessionalProfileRepository), + // which EF Core does not allow concurrent operations against. + // + // Records first, and [StreamRendering] on top: the estimate can take a + // minute against a cold cache, and nobody should wait on Nemotron to see + // their own balances. protected override async Task OnInitializedAsync() + { + await LoadRecordsAsync(); + await LoadMarketPotentialAsync(); + } + + private async Task LoadRecordsAsync() { try { @@ -215,6 +308,34 @@ } } + private async Task LoadMarketPotentialAsync() + { + try + { + var summary = await MarketPotentialSummaryService.GetSummaryAsync(); + HasProfile = summary.HasProfile; + if (!HasProfile) + { + return; + } + + OccupationCount = summary.Occupations.Count; + Compensation = summary.Compensation; + } + catch (OccupationMatchingException ex) + { + PotentialError = ex.Message; + } + catch (CompensationEstimationException ex) + { + PotentialError = ex.Message; + } + finally + { + IsLoadingPotential = false; + } + } + private void OpenAddForm(RecordKind kind) { IsEditing = false; EditingRecord = null; Draft = NewDraft(kind); IsFormOpen = true; } private void OpenEditForm(FinancialRecord record) { IsEditing = true; EditingRecord = record; Draft = new(record.Kind, record.Type, record.Name, record.Amount, record.AsOfDate); IsFormOpen = true; } private void CloseForm() { IsFormOpen = false; } diff --git a/Components/Pages/Financials/NetWorthCalculator.razor.css b/Components/Pages/Financials/NetWorthCalculator.razor.css index ba1aa4c..f7fcf31 100644 --- a/Components/Pages/Financials/NetWorthCalculator.razor.css +++ b/Components/Pages/Financials/NetWorthCalculator.razor.css @@ -159,6 +159,138 @@ button { color: var(--negative-text); } +/* =================================================================== + * Market potential + * =================================================================== + * A quieter pane than the net worth one above it. That is the point: + * this figure deliberately does not join the total, so it must not + * compete with it for attention. + */ + +.potential-summary { + margin-bottom: 1.25rem; + padding: 1.125rem; + border: 1px solid var(--glass-hairline); + border-radius: var(--radius-lg); + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + box-shadow: var(--shadow-1); +} + +.potential-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} + +.potential-label { + margin: 0; + color: var(--text-muted); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.potential-head a { + color: var(--brand); + font-size: 0.8125rem; + font-weight: 600; + text-decoration: none; + white-space: nowrap; +} + +.potential-head a:hover { + text-decoration: underline; +} + +/* Smaller than .headline-amount by design, and it wraps rather than + shrinking: "$78,000 - $104,000" does not fit one line on a 390px + screen at any size worth reading. */ +.potential-amount { + display: block; + margin-top: 0.35rem; + color: var(--text); + font-family: var(--font-numeric); + font-size: clamp(1.5rem, 7vw, 1.875rem); + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + line-height: 1.1; +} + +.potential-note { + margin: 0.5rem 0 0; + color: var(--text-muted); + font-size: 0.8125rem; + line-height: 1.5; +} + +.potential-pending { + margin: 0.5rem 0 0; + color: var(--text-muted); + font-size: 0.9375rem; + line-height: 1.5; +} + +/* =================================================================== + * Inventory disclosure + * =================================================================== */ + +.inventory-panel { + margin-top: 1.25rem; + padding: 0.25rem 1.125rem; + border: 1px solid var(--glass-hairline); + border-radius: var(--radius-lg); + background: var(--glass-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + box-shadow: var(--shadow-1); +} + +/* A grouped-list row rather than a button: full bleed, left aligned, and + tall enough to tap. */ +.inventory-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-height: 2.75rem; + padding: 0.75rem 0; + border: none; + background: none; + color: var(--text); + font: inherit; + font-size: 1.0625rem; + font-weight: 600; + letter-spacing: -0.01em; + text-align: left; +} + +.inventory-toggle span { + color: var(--text-muted); + font-size: 0.875rem; +} + +.inventory-body { + padding-bottom: 1rem; +} + +.inventory-body a { + display: inline-block; + margin-top: 0.75rem; + color: var(--brand); + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; +} + +.inventory-body a:hover { + text-decoration: underline; +} + /* =================================================================== * Segmented control * =================================================================== */ @@ -753,6 +885,20 @@ button { gap: 0.75rem; } + .potential-summary { + margin-bottom: 1.75rem; + padding: 1.5rem; + } + + .potential-amount { + font-size: 2rem; + } + + .inventory-panel { + margin-top: 1.75rem; + padding: 0.25rem 1.5rem; + } + .record-tabs { display: inline-flex; } diff --git a/Features/Dashboard/Dashboard.razor b/Features/Dashboard/Dashboard.razor index e3ae7d6..9d4cec4 100644 --- a/Features/Dashboard/Dashboard.razor +++ b/Features/Dashboard/Dashboard.razor @@ -10,183 +10,339 @@ @inject IPhysicalAssetRepository AssetRepository @inject IMarketPotentialSummaryService MarketPotentialSummaryService -Dashboard +Money Mirror -
-