From 36186e88a639749b8be6ed46ab0244948b70ecf0 Mon Sep 17 00:00:00 2001 From: Andrew Roddy Date: Sun, 20 Sep 2026 08:47:57 -0400 Subject: [PATCH] Removed capital section --- Components/Layout/NavMenu.razor | 17 +- Components/Layout/NavMenu.razor.css | 11 + .../Pages/Financials/NetWorthCalculator.razor | 2 +- .../Financials/NetWorthCalculator.razor.css | 8 - Features/Dashboard/Dashboard.razor | 2 +- Features/HumanCapital/HumanCapital.razor | 284 ++++++++++++++++-- Features/HumanCapital/MarketPotential.razor | 211 ------------- .../HumanCapital/HumanCapitalPageTests.cs | 16 + 8 files changed, 297 insertions(+), 254 deletions(-) delete mode 100644 Features/HumanCapital/MarketPotential.razor diff --git a/Components/Layout/NavMenu.razor b/Components/Layout/NavMenu.razor index 257fce9..b36acdb 100644 --- a/Components/Layout/NavMenu.razor +++ b/Components/Layout/NavMenu.razor @@ -6,7 +6,9 @@ NavMenu.razor.css at the 48rem breakpoint - this markup is identical either way, so there is no second copy of the links to keep in step. - Each link is an icon plus a label. The icon spans are written here + Each link is an icon plus a label; the tail of the two longest labels + is wrapped so the tab bar can drop it and the rail can keep it. The + aria-label holds the full name at either width. The icon spans are written here rather than inside NavLink so they pick up this component's scope attribute; the that NavLink renders does not, which is why the stylesheet reaches it through ::deep. @@ -32,9 +34,9 @@ Financial - + - Physical Assets + Physical Assets @@ -42,14 +44,9 @@ Inventory - + - Human Capital - - - - - Market Potential + Human Capital diff --git a/Components/Layout/NavMenu.razor.css b/Components/Layout/NavMenu.razor.css index 21479fe..010ee81 100644 --- a/Components/Layout/NavMenu.razor.css +++ b/Components/Layout/NavMenu.razor.css @@ -181,6 +181,13 @@ text-align: center; } +/* A five-tab row has no width for "Physical Assets" or "Human Capital", so + the tab bar shows only the first word. The rail below restores the rest; + the aria-label on the link carries the full name at either width. */ +.nav-label-rest { + display: none; +} + /* =================================================================== * Side rail * =================================================================== */ @@ -255,6 +262,10 @@ line-height: 1.3; text-align: left; } + + .nav-label-rest { + display: inline; + } } /* =================================================================== diff --git a/Components/Pages/Financials/NetWorthCalculator.razor b/Components/Pages/Financials/NetWorthCalculator.razor index 7b3a935..d6cabdf 100644 --- a/Components/Pages/Financials/NetWorthCalculator.razor +++ b/Components/Pages/Financials/NetWorthCalculator.razor @@ -51,7 +51,7 @@
Physical capital
-
@TotalPhysicalCapital.ToString("C0") View inventory
+
@TotalPhysicalCapital.ToString("C0")
Total liabilities
diff --git a/Components/Pages/Financials/NetWorthCalculator.razor.css b/Components/Pages/Financials/NetWorthCalculator.razor.css index df8377e..ba1aa4c 100644 --- a/Components/Pages/Financials/NetWorthCalculator.razor.css +++ b/Components/Pages/Financials/NetWorthCalculator.razor.css @@ -159,14 +159,6 @@ button { color: var(--negative-text); } -.breakdown-item a { - display: block; - margin: 0.15rem 0 0; - color: var(--brand); - font-size: 0.75rem; - font-weight: 500; -} - /* =================================================================== * Segmented control * =================================================================== */ diff --git a/Features/Dashboard/Dashboard.razor b/Features/Dashboard/Dashboard.razor index 3812596..e3ae7d6 100644 --- a/Features/Dashboard/Dashboard.razor +++ b/Features/Dashboard/Dashboard.razor @@ -71,7 +71,7 @@ based on @_occupationCount matched occupation@(_occupationCount == 1 ? "" : "s")

} - View Market Potential + View Market Potential
diff --git a/Features/HumanCapital/HumanCapital.razor b/Features/HumanCapital/HumanCapital.razor index e3c9c90..216342a 100644 --- a/Features/HumanCapital/HumanCapital.razor +++ b/Features/HumanCapital/HumanCapital.razor @@ -1,4 +1,6 @@ @page "/human-capital" +@page "/market-potential" +@attribute [StreamRendering(true)] @rendermode InteractiveServer @using Microsoft.Extensions.Options @using MoneyMirror.HumanCapital @@ -6,8 +8,8 @@ @inject IResumeTextExtractionService TextExtractionService @inject IProfessionalProfileExtractionService ProfileExtractionService @inject IProfessionalProfileRepository ProfileRepository +@inject IMarketPotentialSummaryService MarketPotentialSummaryService @inject IOptions UploadOptions -@inject NavigationManager Navigation Human Capital @@ -22,15 +24,26 @@

Resume

PDF and DOCX files are supported. Your resume is used to build an editable professional profile.

- - - @if (_profile is null) - { - or - - } +
+ + + @if (_profile is null) + { + or + + } + else + { + @* The editable profile is long enough to bury everything below it, so it + sits behind this toggle alongside the upload button rather than being + permanently open. *@ + + } +
@if (_isProcessing) @@ -46,17 +59,17 @@ } -@if (_profile is not null) +@if (_savedMessage is not null) { -
+ +} + +@if (_profile is not null && _showProfile) +{ +

Your Professional Profile

Add or correct anything below, then save.

- @if (_savedMessage is not null) - { - - } -
@@ -132,6 +145,161 @@
} + +

Market Potential

+ + + +@if (_marketPotentialError is not null) +{ + +} + +@if (_isLoadingMarketPotential) +{ +

Loading...

+} +else if (_hasNoProfile) +{ +

+ No profile on file yet. Upload a resume, or start from scratch, above to get started. +

+} +else +{ +

+ Based on your professional profile above. Open it to review or correct anything that looks wrong. +

+ +

Estimated Compensation Range

+ @if (_compensation is null) + { +

Not available.

+ } + else + { +

+ $@_compensation.MinUsd.ToString("N0") – $@_compensation.MaxUsd.ToString("N0") + AI estimate - not based on live wage data +

+

@_compensation.Explanation

+ } + +
+

Matched Occupations

+ @if (_occupations is null || _occupations.Count == 0) + { +

No matches available.

+ } + else + { +
    + @foreach (var occupation in _occupations) + { +
  • + @occupation.Title + AI-suggested + @if (occupation.TypicalMinUsd is not null && occupation.TypicalMaxUsd is not null) + { + - $@occupation.TypicalMinUsd.Value.ToString("N0") – $@occupation.TypicalMaxUsd.Value.ToString("N0") + } +

    @occupation.Explanation

    +
  • + } +
+ +

Marketable Skills

+

Skills you already have that the matched occupations above are looking for.

+ @if (_skillGaps is null || _skillGaps.MarketableSkills.Count == 0) + { +

None of your listed skills matched the occupations above yet.

+ } + else + { +
    + @foreach (var signal in _skillGaps.MarketableSkills) + { +
  • + @signal.Skill + - wanted for @string.Join(", ", signal.Occupations) +
  • + } +
+ } + +

Skill Gaps

+

In-demand skills the matched occupations above want that aren't in your profile yet.

+ @if (_skillGaps is null || _skillGaps.SkillGaps.Count == 0) + { +

No gaps identified from the occupations above.

+ } + else + { +
    + @foreach (var signal in _skillGaps.SkillGaps) + { +
  • + @signal.Skill + - wanted for @string.Join(", ", signal.Occupations) +
  • + } +
+ } + +

Potential Growth Path

+

+ The matched occupations above, ordered by estimated typical pay - an illustrative path + across occupations you're already matched to, not a guaranteed timeline. +

+ @if (_progression is null || _progression.Count == 0) + { +

Not enough compensation data on the matched occupations to build a path.

+ } + else + { +
    + @foreach (var step in _progression) + { +
  1. @step.OccupationTitle (~$@step.MidpointUsd.ToString("N0"))
  2. + } +
+ } + +

Opportunities

+

+ Matched occupations that typically pay more than your lowest matched occupation, where + you're only missing a few of the skills they want. +

+ @if (_opportunities is null || _opportunities.Count == 0) + { +

No standout opportunities identified from the occupations above.

+ } + else + { +
    + @foreach (var opportunity in _opportunities) + { +
  • + @opportunity.OccupationTitle + @if (opportunity.TypicalMinUsd is not null && opportunity.TypicalMaxUsd is not null) + { + - $@opportunity.TypicalMinUsd.Value.ToString("N0") – $@opportunity.TypicalMaxUsd.Value.ToString("N0") + } +

    @opportunity.Explanation

    + @if (opportunity.MissingSkills.Count > 0) + { +

    Skills to close the gap: @string.Join(", ", opportunity.MissingSkills)

    + } +
  • + } +
+ } + } +
+} @code { @@ -142,6 +310,16 @@ private string? _savedMessage; private string? _fileName; private ProfessionalProfile? _profile; + private bool _showProfile; + + private bool _isLoadingMarketPotential = true; + private bool _hasNoProfile; + private string? _marketPotentialError; + private IReadOnlyList? _occupations; + private CompensationEstimate? _compensation; + private SkillGapAnalysis? _skillGaps; + private IReadOnlyList? _progression; + private IReadOnlyList? _opportunities; private List _education = []; private List _experience = []; @@ -162,6 +340,12 @@ _profile = profile; LoadEditableLists(profile); } + + // Sequential rather than concurrent: this and the profile read above + // share the same scoped DbContext (directly, and via + // IProfessionalProfileRepository), which EF Core does not allow + // concurrent operations against. + await LoadMarketPotentialAsync(); } private static bool HasAnyContent(ProfessionalProfile profile) => @@ -173,12 +357,18 @@ || profile.Publications.Count > 0 || profile.Awards.Count > 0; + private void ToggleProfile() => _showProfile = !_showProfile; + private void StartFromScratch() { _errorMessage = null; _savedMessage = null; _profile = ProfessionalProfile.Empty; LoadEditableLists(_profile); + + // Nothing to fill in yet, so open the editor rather than leaving the + // click looking like it did nothing. + _showProfile = true; } private async Task OnFileSelectedAsync(InputFileChangeEventArgs e) @@ -210,6 +400,10 @@ StateHasChanged(); _profile = await ProfileExtractionService.ExtractAsync(text); LoadEditableLists(_profile); + + // A fresh extraction is exactly what the user should be checking, + // so show it without making them open the toggle first. + _showProfile = true; } catch (ResumeTextExtractionException ex) { @@ -263,15 +457,18 @@ try { await ProfileRepository.SaveAsync(_profile); - _savedMessage = "Corrections saved. Taking you to your Market Potential..."; + _savedMessage = "Corrections saved. Updating your Market Potential..."; _isSaving = false; + + // #113 carried the user straight through to occupation matching / + // Market Potential rather than requiring manual navigation. Now + // that Market Potential is a section of this page, the equivalent + // is to fold the editor away and recompute the estimate in place. + _showProfile = false; StateHasChanged(); - // #113: carry the user straight through to occupation matching / - // Market Potential rather than requiring manual navigation - the - // brief pause just lets them see the confirmation above first. - await Task.Delay(TimeSpan.FromSeconds(1.5)); - Navigation.NavigateTo("/market-potential"); + await LoadMarketPotentialAsync(); + _savedMessage = "Corrections saved. Your Market Potential is up to date."; } catch (Exception ex) { @@ -280,6 +477,47 @@ } } + /// + /// Market Potential is a section of this page rather than its own + /// /market-potential route, so it is recomputed in place after the + /// profile is edited instead of by navigating away and back. + /// + private async Task LoadMarketPotentialAsync() + { + _isLoadingMarketPotential = true; + _marketPotentialError = null; + _hasNoProfile = false; + StateHasChanged(); + + try + { + var summary = await MarketPotentialSummaryService.GetSummaryAsync(); + if (!summary.HasProfile) + { + _hasNoProfile = true; + return; + } + + _occupations = summary.Occupations; + _compensation = summary.Compensation; + _skillGaps = SkillGapAnalyzer.Analyze(summary.Profile.Skills, _occupations); + _progression = OpportunityFinder.BuildProgression(_occupations); + _opportunities = OpportunityFinder.FindOpportunities(summary.Profile.Skills, _occupations); + } + catch (OccupationMatchingException ex) + { + _marketPotentialError = ex.Message; + } + catch (CompensationEstimationException ex) + { + _marketPotentialError = ex.Message; + } + finally + { + _isLoadingMarketPotential = false; + } + } + private class EditableEducation { public string Institution { get; set; } = string.Empty; diff --git a/Features/HumanCapital/MarketPotential.razor b/Features/HumanCapital/MarketPotential.razor deleted file mode 100644 index 9176729..0000000 --- a/Features/HumanCapital/MarketPotential.razor +++ /dev/null @@ -1,211 +0,0 @@ -@page "/market-potential" -@attribute [StreamRendering(true)] -@rendermode InteractiveServer -@using MoneyMirror.HumanCapital -@inject IMarketPotentialSummaryService MarketPotentialSummaryService - -Market Potential - -
- - - - -@if (_errorMessage is not null) -{ - -} - -@if (_isLoading) -{ -

Loading...

-} -else if (_hasNoProfile) -{ -

- No profile on file yet. Upload a resume to get started. -

-} -else -{ -

- Based on the profile below. Review or correct it if something looks wrong. -

- -

Estimated Compensation Range

- @if (_compensation is null) - { -

Not available.

- } - else - { -

- $@_compensation.MinUsd.ToString("N0") – $@_compensation.MaxUsd.ToString("N0") - AI estimate - not based on live wage data -

-

@_compensation.Explanation

- } - -
-

Matched Occupations

- @if (_occupations is null || _occupations.Count == 0) - { -

No matches available.

- } - else - { -
    - @foreach (var occupation in _occupations) - { -
  • - @occupation.Title - AI-suggested - @if (occupation.TypicalMinUsd is not null && occupation.TypicalMaxUsd is not null) - { - - $@occupation.TypicalMinUsd.Value.ToString("N0") – $@occupation.TypicalMaxUsd.Value.ToString("N0") - } -

    @occupation.Explanation

    -
  • - } -
- -

Marketable Skills

-

Skills you already have that the matched occupations above are looking for.

- @if (_skillGaps is null || _skillGaps.MarketableSkills.Count == 0) - { -

None of your listed skills matched the occupations above yet.

- } - else - { -
    - @foreach (var signal in _skillGaps.MarketableSkills) - { -
  • - @signal.Skill - - wanted for @string.Join(", ", signal.Occupations) -
  • - } -
- } - -

Skill Gaps

-

In-demand skills the matched occupations above want that aren't in your profile yet.

- @if (_skillGaps is null || _skillGaps.SkillGaps.Count == 0) - { -

No gaps identified from the occupations above.

- } - else - { -
    - @foreach (var signal in _skillGaps.SkillGaps) - { -
  • - @signal.Skill - - wanted for @string.Join(", ", signal.Occupations) -
  • - } -
- } - -

Potential Growth Path

-

- The matched occupations above, ordered by estimated typical pay - an illustrative path - across occupations you're already matched to, not a guaranteed timeline. -

- @if (_progression is null || _progression.Count == 0) - { -

Not enough compensation data on the matched occupations to build a path.

- } - else - { -
    - @foreach (var step in _progression) - { -
  1. @step.OccupationTitle (~$@step.MidpointUsd.ToString("N0"))
  2. - } -
- } - -

Opportunities

-

- Matched occupations that typically pay more than your lowest matched occupation, where - you're only missing a few of the skills they want. -

- @if (_opportunities is null || _opportunities.Count == 0) - { -

No standout opportunities identified from the occupations above.

- } - else - { -
    - @foreach (var opportunity in _opportunities) - { -
  • - @opportunity.OccupationTitle - @if (opportunity.TypicalMinUsd is not null && opportunity.TypicalMaxUsd is not null) - { - - $@opportunity.TypicalMinUsd.Value.ToString("N0") – $@opportunity.TypicalMaxUsd.Value.ToString("N0") - } -

    @opportunity.Explanation

    - @if (opportunity.MissingSkills.Count > 0) - { -

    Skills to close the gap: @string.Join(", ", opportunity.MissingSkills)

    - } -
  • - } -
- } - } -
-} -
- -@code { - private bool _isLoading = true; - private bool _hasNoProfile; - private string? _errorMessage; - private IReadOnlyList? _occupations; - private CompensationEstimate? _compensation; - private SkillGapAnalysis? _skillGaps; - private IReadOnlyList? _progression; - private IReadOnlyList? _opportunities; - - protected override async Task OnInitializedAsync() - { - try - { - var summary = await MarketPotentialSummaryService.GetSummaryAsync(); - if (!summary.HasProfile) - { - _hasNoProfile = true; - return; - } - - _occupations = summary.Occupations; - _compensation = summary.Compensation; - _skillGaps = SkillGapAnalyzer.Analyze(summary.Profile.Skills, _occupations); - _progression = OpportunityFinder.BuildProgression(_occupations); - _opportunities = OpportunityFinder.FindOpportunities(summary.Profile.Skills, _occupations); - } - catch (OccupationMatchingException ex) - { - _errorMessage = ex.Message; - } - catch (CompensationEstimationException ex) - { - _errorMessage = ex.Message; - } - finally - { - _isLoading = false; - } - } -} diff --git a/tests/MoneyMirror.Tests/HumanCapital/HumanCapitalPageTests.cs b/tests/MoneyMirror.Tests/HumanCapital/HumanCapitalPageTests.cs index 11925d4..79e47bc 100644 --- a/tests/MoneyMirror.Tests/HumanCapital/HumanCapitalPageTests.cs +++ b/tests/MoneyMirror.Tests/HumanCapital/HumanCapitalPageTests.cs @@ -45,6 +45,16 @@ public Task ExtractAsync( ) => throw new InvalidOperationException("Not exercised by these tests."); } + private class NoProfileMarketPotentialSummaryService : IMarketPotentialSummaryService + { + public Task GetSummaryAsync( + CancellationToken cancellationToken = default + ) => + Task.FromResult( + new MarketPotentialSummary(ProfessionalProfile.Empty, HasProfile: false, [], null) + ); + } + public HumanCapitalPageTests() { _connection.Open(); @@ -65,6 +75,9 @@ public HumanCapitalPageTests() _context.Services.AddSingleton>( Options.Create(new ResumeUploadOptions()) ); + _context.Services.AddSingleton( + new NoProfileMarketPotentialSummaryService() + ); } public void Dispose() @@ -111,6 +124,9 @@ ProfessionalProfile.Empty with var page = _context.Render(); + Assert.Empty(page.FindAll("h2:contains('Your Professional Profile')")); + page.Find("button:contains('Your Professional Profile')").Click(); + Assert.NotEmpty(page.FindAll("h2:contains('Your Professional Profile')")); Assert.Equal("C#", page.Find("input[placeholder='Skill']").GetAttribute("value"));