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:
parent
b27268febe
commit
5412dda2ac
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -417,6 +433,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
PortfolioValueCents: portfolioValueCents,
|
PortfolioValueCents: portfolioValueCents,
|
||||||
PortfolioPCLCents: portfolioPCLCents,
|
PortfolioPCLCents: portfolioPCLCents,
|
||||||
PortfolioHoldings: portfolioHoldings,
|
PortfolioHoldings: portfolioHoldings,
|
||||||
|
PortfolioPricesAvailable: portfolioPricesAvailable,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -149,6 +149,7 @@ type DashboardData struct {
|
|||||||
PortfolioValueCents int64
|
PortfolioValueCents int64
|
||||||
PortfolioPCLCents int64
|
PortfolioPCLCents int64
|
||||||
PortfolioHoldings []Holding
|
PortfolioHoldings []Holding
|
||||||
|
PortfolioPricesAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeriodSummary struct {
|
type PeriodSummary struct {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user