fix: dashboard investment display and budget health

- Exclude FixedCategories (Housing, Utilities, Subscriptions, Investments)
  from budget health panel — they are committed costs, not variable spend
- Portfolio card and stocks panel now degrade gracefully to cost basis
  when Yahoo Finance prices are unavailable, instead of showing €0
- Stocks panel shows "cost basis" label per holding when live prices
  are not available

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-13 16:14:44 +01:00
parent b27268febe
commit 5412dda2ac
3 changed files with 42 additions and 15 deletions

View File

@ -223,7 +223,8 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
for _, c := range cats { for _, c := range cats {
catNames[c.Name] = c.Name catNames[c.Name] = c.Name
catColors[c.Name] = c.Color catColors[c.Name] = c.Color
if c.BudgetCents > 0 { // exclude fixed categories from budget health — they're committed costs, not variable spend
if c.BudgetCents > 0 && !FixedCategories[c.Name] {
catBudgets[c.Name] = c.BudgetCents catBudgets[c.Name] = c.BudgetCents
} }
} }
@ -379,15 +380,30 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
lastMonthSavingsRatePct = int(float64(lastMonthSavings) / float64(lastMonthIncome) * 100) lastMonthSavingsRatePct = int(float64(lastMonthSavings) / float64(lastMonthIncome) * 100)
} }
// portfolio snapshot (best-effort, ignore errors) // portfolio snapshot — degrade to cost basis if live prices are unavailable
var portfolioValueCents, portfolioPCLCents int64 var portfolioValueCents, portfolioPCLCents int64
var portfolioHoldings []Holding var portfolioHoldings []Holding
var portfolioPricesAvailable bool
if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 { if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 {
if prices, err := fetchPricesByISIN(uniqueISINs(trades)); err == nil { prices, _ := fetchPricesByISIN(uniqueISINs(trades))
pr := aggregatePortfolio(computeHoldings(trades, prices)) holdings := computeHoldings(trades, prices)
pr := aggregatePortfolio(holdings)
portfolioHoldings = pr.Holdings
// check whether any prices came back
for _, p := range prices {
if p > 0 {
portfolioPricesAvailable = true
break
}
}
if portfolioPricesAvailable {
portfolioValueCents = pr.TotalVal portfolioValueCents = pr.TotalVal
portfolioPCLCents = pr.TotalPCL portfolioPCLCents = pr.TotalPCL
portfolioHoldings = pr.Holdings } else {
// fall back to cost basis so the card is still useful
portfolioValueCents = pr.TotalCost
} }
} }
@ -414,9 +430,10 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
SafetyBufferCents: safetyBuffer, SafetyBufferCents: safetyBuffer,
SavingsRatePct: savingsRatePct, SavingsRatePct: savingsRatePct,
LastMonthSavingsRatePct: lastMonthSavingsRatePct, LastMonthSavingsRatePct: lastMonthSavingsRatePct,
PortfolioValueCents: portfolioValueCents, PortfolioValueCents: portfolioValueCents,
PortfolioPCLCents: portfolioPCLCents, PortfolioPCLCents: portfolioPCLCents,
PortfolioHoldings: portfolioHoldings, PortfolioHoldings: portfolioHoldings,
PortfolioPricesAvailable: portfolioPricesAvailable,
}) })
} }

View File

@ -146,9 +146,10 @@ type DashboardData struct {
SavingsRatePct int // savings / income * 100 this month SavingsRatePct int // savings / income * 100 this month
LastMonthSavingsRatePct int LastMonthSavingsRatePct int
PortfolioValueCents int64 PortfolioValueCents int64
PortfolioPCLCents int64 PortfolioPCLCents int64
PortfolioHoldings []Holding PortfolioHoldings []Holding
PortfolioPricesAvailable bool
} }
type PeriodSummary struct { type PeriodSummary struct {

View File

@ -57,12 +57,16 @@
<div class="card value-card animate-on-scroll"> <div class="card value-card animate-on-scroll">
<h2>Portfolio today</h2> <h2>Portfolio today</h2>
{{if $d.PortfolioValueCents}} {{if $d.PortfolioHoldings}}
<div class="value animate-counter" data-target="{{$d.PortfolioValueCents}}" data-prefix="€" style="color:var(--text);">€0.00</div> <div class="value animate-counter" data-target="{{$d.PortfolioValueCents}}" data-prefix="€" style="color:var(--text);">€0.00</div>
{{if $d.PortfolioPricesAvailable}}
<p style="font-size:12px; margin-top:6px; {{if ge $d.PortfolioPCLCents 0}}color:var(--green){{else}}color:var(--red){{end}};"> <p style="font-size:12px; margin-top:6px; {{if ge $d.PortfolioPCLCents 0}}color:var(--green){{else}}color:var(--red){{end}};">
{{if ge $d.PortfolioPCLCents 0}}+{{else}}{{end}}€{{cents (centsAbs $d.PortfolioPCLCents)}} total P&L {{if ge $d.PortfolioPCLCents 0}}+{{else}}{{end}}€{{cents (centsAbs $d.PortfolioPCLCents)}} total P&L
</p> </p>
{{else}} {{else}}
<p style="font-size:12px; color:var(--text3); margin-top:6px;">cost basis · prices unavailable</p>
{{end}}
{{else}}
<div class="value" style="color:var(--text3); font-size:18px;">No trades yet</div> <div class="value" style="color:var(--text3); font-size:18px;">No trades yet</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;"><a href="/import" style="color:var(--accent);">Import trades →</a></p> <p style="font-size:12px; color:var(--text3); margin-top:6px;"><a href="/import" style="color:var(--accent);">Import trades →</a></p>
{{end}} {{end}}
@ -120,19 +124,24 @@
<div style="display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border);"> <div style="display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border);">
<div> <div>
<div style="font-size:13px; font-weight:600; color:var(--text);">{{.Name}}</div> <div style="font-size:13px; font-weight:600; color:var(--text);">{{.Name}}</div>
<div style="font-size:11px; color:var(--text3);">{{.ISIN}} · {{printf "%.2f" .SharesOwned}} shares</div> <div style="font-size:11px; color:var(--text3);">{{printf "%.4f" .SharesOwned}} shares</div>
</div> </div>
<div style="text-align:right;"> <div style="text-align:right;">
{{if $d.PortfolioPricesAvailable}}
<div style="font-size:13px; font-weight:500; color:var(--text);">€{{cents .CurrentValueCents}}</div> <div style="font-size:13px; font-weight:500; color:var(--text);">€{{cents .CurrentValueCents}}</div>
<div style="font-size:12px; {{if ge .UnrealizedPCLCents 0}}color:var(--green){{else}}color:var(--red){{end}};"> <div style="font-size:12px; {{if ge .UnrealizedPCLCents 0}}color:var(--green){{else}}color:var(--red){{end}};">
{{pctSign .UnrealizedPCLPct}}{{printf "%.1f" .UnrealizedPCLPct}}% {{pctSign .UnrealizedPCLPct}}{{printf "%.1f" .UnrealizedPCLPct}}%
</div> </div>
{{else}}
<div style="font-size:13px; font-weight:500; color:var(--text);">€{{cents .TotalCostCents}}</div>
<div style="font-size:11px; color:var(--text3);">cost basis</div>
{{end}}
</div> </div>
</div> </div>
{{end}} {{end}}
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;"> <div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
<span style="font-size:13px; font-weight:500; color:var(--text);">Total</span> <span style="font-size:13px; font-weight:500; color:var(--text);">Total{{if not $d.PortfolioPricesAvailable}} invested{{end}}</span>
<span class="animate-counter" style="font-size:15px; font-weight:600; {{if ge $d.PortfolioPCLCents 0}}color:var(--green){{else}}color:var(--red){{end}};" <span class="animate-counter" style="font-size:15px; font-weight:600; color:var(--text);"
data-target="{{$d.PortfolioValueCents}}" data-prefix="€">€0.00</span> data-target="{{$d.PortfolioValueCents}}" data-prefix="€">€0.00</span>
</div> </div>
</div> </div>