Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions Components/Pages/Financials/NetWorthCalculator.razor
Original file line number Diff line number Diff line change
@@ -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

<PageTitle>Net Worth</PageTitle>

Expand Down Expand Up @@ -60,6 +63,50 @@
</dl>
</section>

@*
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.
*@
<section class="potential-summary" aria-label="Market potential">
<div class="potential-head">
<p class="potential-label">Market potential</p>
<a href="/market-potential">Details &rarr;</a>
</div>

@if (IsLoadingPotential)
{
<p class="potential-pending"><em>Estimating...</em></p>
}
else if (PotentialError is not null)
{
<p class="potential-pending">@PotentialError</p>
}
else if (!HasProfile)
{
<p class="potential-pending">
No profile on file yet. <a href="/human-capital">Upload a resume</a> to see
what the market pays for what you already know.
</p>
}
else if (Compensation is null)
{
<p class="potential-pending">Not available for this profile.</p>
}
else
{
<strong class="potential-amount">
$@Compensation.MinUsd.ToString("N0") &ndash; $@Compensation.MaxUsd.ToString("N0")
</strong>
<p class="potential-note">
<span class="badge bg-secondary">AI estimate</span>
across @OccupationCount matched occupation@(OccupationCount == 1 ? "" : "s"). Kept
out of the net worth above on purpose.
</p>
}
</section>

<div class="records-toolbar">
<div class="record-tabs" role="tablist" aria-label="Record type">
<button class="record-tab @(ActiveTab == RecordKind.All ? "selected" : "")" @onclick="() => ActiveTab = RecordKind.All">
Expand Down Expand Up @@ -145,6 +192,12 @@
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 FinancialRecord Draft { get; set; } = NewDraft(RecordKind.Asset);
private FinancialRecord? EditingRecord { get; set; }

Expand Down Expand Up @@ -175,7 +228,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
{
Expand Down Expand Up @@ -215,6 +284,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; }
Expand Down
85 changes: 85 additions & 0 deletions Components/Pages/Financials/NetWorthCalculator.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,82 @@ 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;
}

/* ===================================================================
* Segmented control
* =================================================================== */
Expand Down Expand Up @@ -753,6 +829,15 @@ button {
gap: 0.75rem;
}

.potential-summary {
margin-bottom: 1.75rem;
padding: 1.5rem;
}

.potential-amount {
font-size: 2rem;
}

.record-tabs {
display: inline-flex;
}
Expand Down
Loading
Loading