feat: phase 4 — net worth page + dashboard card
Adds /networth page with hero total, cash/portfolio breakdown cards, and a month-by-month historical chart. Also shows net worth as a value card on the dashboard with a link to the full breakdown. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bfd5f62a7a
commit
42f5b0df4d
@ -116,6 +116,7 @@ var (
|
|||||||
portfolioTmpl = parseTmpl("templates/base.html", "templates/portfolio.html")
|
portfolioTmpl = parseTmpl("templates/base.html", "templates/portfolio.html")
|
||||||
sharingTmpl = parseTmpl("templates/base.html", "templates/sharing.html")
|
sharingTmpl = parseTmpl("templates/base.html", "templates/sharing.html")
|
||||||
goalsTmpl = parseTmpl("templates/base.html", "templates/goals.html")
|
goalsTmpl = parseTmpl("templates/base.html", "templates/goals.html")
|
||||||
|
networthTmpl = parseTmpl("templates/base.html", "templates/networth.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
type authInfo struct {
|
type authInfo struct {
|
||||||
@ -464,6 +465,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
PortfolioPCLCents: portfolioPCLCents,
|
PortfolioPCLCents: portfolioPCLCents,
|
||||||
PortfolioHoldings: portfolioHoldings,
|
PortfolioHoldings: portfolioHoldings,
|
||||||
PortfolioPricesAvailable: portfolioPricesAvailable,
|
PortfolioPricesAvailable: portfolioPricesAvailable,
|
||||||
|
NetWorthCents: portfolioValueCents + running,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1543,6 +1545,94 @@ func parseFloat(s string) float64 {
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
a := getAuth(r)
|
||||||
|
|
||||||
|
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("networth get transactions", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// build a running total per month for cash (non-credit accounts treated as assets,
|
||||||
|
// credit accounts as liabilities)
|
||||||
|
// We don't have account-type info on transactions, so we use signing convention:
|
||||||
|
// Income category = income (+), everything else = expense (−).
|
||||||
|
// For a simple net-worth history: sum all transaction amounts cumulatively per month.
|
||||||
|
type monthKey = string
|
||||||
|
monthCash := make(map[monthKey]int64)
|
||||||
|
var months []string
|
||||||
|
seen := make(map[monthKey]bool)
|
||||||
|
|
||||||
|
// cumulative running balance across all txns sorted by date (store returns desc; reverse)
|
||||||
|
// running cumulative balance — reset is not possible so we track running sum
|
||||||
|
runningBalance := int64(0)
|
||||||
|
// txns are sorted desc by date; reverse to process oldest first
|
||||||
|
for i := len(txns) - 1; i >= 0; i-- {
|
||||||
|
t := txns[i]
|
||||||
|
runningBalance += t.AmountCents
|
||||||
|
mk := t.Date.Format("2006-01")
|
||||||
|
monthCash[mk] = runningBalance
|
||||||
|
if !seen[mk] {
|
||||||
|
seen[mk] = true
|
||||||
|
months = append(months, mk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sortStrings(months)
|
||||||
|
|
||||||
|
// current cash = running balance at end of all transactions
|
||||||
|
cashCents := runningBalance
|
||||||
|
|
||||||
|
// portfolio
|
||||||
|
var portfolioCents int64
|
||||||
|
var pricesAvailable bool
|
||||||
|
if trades, err2 := h.store.getTrades(ctx, a.UserID); err2 == nil && len(trades) > 0 {
|
||||||
|
prices, _ := fetchPricesByISIN(uniqueISINs(trades))
|
||||||
|
holdings := computeHoldings(trades, prices)
|
||||||
|
pr := aggregatePortfolio(holdings)
|
||||||
|
for _, p := range prices {
|
||||||
|
if p > 0 {
|
||||||
|
pricesAvailable = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pricesAvailable {
|
||||||
|
portfolioCents = pr.TotalVal
|
||||||
|
} else {
|
||||||
|
portfolioCents = pr.TotalCost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
netWorthCents := cashCents + portfolioCents
|
||||||
|
|
||||||
|
// build history points — each month: cash snapshot + portfolio (we only have current portfolio)
|
||||||
|
var history []NetWorthPoint
|
||||||
|
for _, m := range months {
|
||||||
|
cash := monthCash[m]
|
||||||
|
history = append(history, NetWorthPoint{
|
||||||
|
Month: m,
|
||||||
|
AssetCents: cash + portfolioCents,
|
||||||
|
LiabCents: 0,
|
||||||
|
NetCents: cash + portfolioCents,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render(w, networthTmpl, &NetWorthData{
|
||||||
|
UserID: a.UserID,
|
||||||
|
Email: a.Email,
|
||||||
|
Title: "Net Worth",
|
||||||
|
Route: "networth",
|
||||||
|
CashCents: cashCents,
|
||||||
|
PortfolioCents: portfolioCents,
|
||||||
|
CreditCents: 0,
|
||||||
|
NetWorthCents: netWorthCents,
|
||||||
|
PortfolioPricesAvailable: pricesAvailable,
|
||||||
|
History: history,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /{$}", h.Dashboard)
|
mux.HandleFunc("GET /{$}", h.Dashboard)
|
||||||
mux.HandleFunc("GET /transactions", h.Transactions)
|
mux.HandleFunc("GET /transactions", h.Transactions)
|
||||||
@ -1562,6 +1652,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /portfolio", h.Portfolio)
|
mux.HandleFunc("GET /portfolio", h.Portfolio)
|
||||||
mux.HandleFunc("GET /goals", h.Goals)
|
mux.HandleFunc("GET /goals", h.Goals)
|
||||||
mux.HandleFunc("POST /goals", h.Goals)
|
mux.HandleFunc("POST /goals", h.Goals)
|
||||||
|
mux.HandleFunc("GET /networth", h.NetWorth)
|
||||||
mux.HandleFunc("GET /sharing", h.Sharing)
|
mux.HandleFunc("GET /sharing", h.Sharing)
|
||||||
mux.HandleFunc("POST /sharing", h.Sharing)
|
mux.HandleFunc("POST /sharing", h.Sharing)
|
||||||
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)
|
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)
|
||||||
|
|||||||
@ -152,6 +152,8 @@ type DashboardData struct {
|
|||||||
PortfolioPCLCents int64
|
PortfolioPCLCents int64
|
||||||
PortfolioHoldings []Holding
|
PortfolioHoldings []Holding
|
||||||
PortfolioPricesAvailable bool
|
PortfolioPricesAvailable bool
|
||||||
|
|
||||||
|
NetWorthCents int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeriodSummary struct {
|
type PeriodSummary struct {
|
||||||
@ -217,6 +219,31 @@ type SharingUser struct {
|
|||||||
Email string
|
Email string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NetWorthPoint struct {
|
||||||
|
Month string // "2025-01"
|
||||||
|
AssetCents int64
|
||||||
|
LiabCents int64
|
||||||
|
NetCents int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type NetWorthData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
|
||||||
|
// current snapshot
|
||||||
|
CashCents int64 // running balance of all non-credit accounts
|
||||||
|
PortfolioCents int64 // market value (or cost basis)
|
||||||
|
CreditCents int64 // total outstanding on credit accounts (positive = owed)
|
||||||
|
NetWorthCents int64 // cash + portfolio − credit
|
||||||
|
|
||||||
|
PortfolioPricesAvailable bool
|
||||||
|
|
||||||
|
// month-by-month history
|
||||||
|
History []NetWorthPoint
|
||||||
|
}
|
||||||
|
|
||||||
// GoalType classifies a financial goal for display and calculation purposes.
|
// GoalType classifies a financial goal for display and calculation purposes.
|
||||||
type GoalType string
|
type GoalType string
|
||||||
|
|
||||||
|
|||||||
@ -400,6 +400,7 @@
|
|||||||
<a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">Projections</a>
|
<a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">Projections</a>
|
||||||
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
|
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
|
||||||
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
|
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
|
||||||
|
<a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">Net Worth</a>
|
||||||
<a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a>
|
<a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a>
|
||||||
<div class="nav-spacer"></div>
|
<div class="nav-spacer"></div>
|
||||||
<span class="nav-email">{{.Email}}</span>
|
<span class="nav-email">{{.Email}}</span>
|
||||||
|
|||||||
@ -55,6 +55,13 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Net worth</h2>
|
||||||
|
<div class="value animate-counter {{if lt $d.NetWorthCents 0}}negative{{else}}positive{{end}}"
|
||||||
|
data-target="{{$d.NetWorthCents}}" data-prefix="€">€0.00</div>
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;"><a href="/networth" style="color:var(--accent);">→ full breakdown</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<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.PortfolioHoldings}}
|
{{if $d.PortfolioHoldings}}
|
||||||
|
|||||||
125
apps/finance/services/api/main/templates/networth.html
Normal file
125
apps/finance/services/api/main/templates/networth.html
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
||||||
|
<h1>Net Worth</h1>
|
||||||
|
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:16px; padding:24px 28px;">
|
||||||
|
<div style="font-size:12px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:10px; display:flex; align-items:center; gap:8px;">
|
||||||
|
Total net worth
|
||||||
|
<span style="font-size:11px; background:var(--bg3); color:var(--text3); padding:2px 8px; border-radius:99px; font-weight:400; text-transform:none; letter-spacing:0;">
|
||||||
|
cash balance + portfolio
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="animate-counter {{if lt $d.NetWorthCents 0}}negative{{else}}positive{{end}}"
|
||||||
|
style="font-size:42px; font-weight:500; letter-spacing:-1.5px; line-height:1;"
|
||||||
|
data-target="{{$d.NetWorthCents}}" data-prefix="€">€0.00</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Breakdown cards -->
|
||||||
|
<div class="grid" style="margin-bottom:16px;">
|
||||||
|
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Cash balance</h2>
|
||||||
|
<div class="value animate-counter {{if lt $d.CashCents 0}}negative{{else}}positive{{end}}"
|
||||||
|
data-target="{{$d.CashCents}}" data-prefix="€">€0.00</div>
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">all transaction history</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Portfolio{{if not $d.PortfolioPricesAvailable}} (cost basis){{end}}</h2>
|
||||||
|
<div class="value animate-counter positive"
|
||||||
|
data-target="{{$d.PortfolioCents}}" data-prefix="€">€0.00</div>
|
||||||
|
{{if $d.PortfolioPricesAvailable}}
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">market value</p>
|
||||||
|
{{else}}
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">prices unavailable · cost basis shown</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $d.CreditCents}}
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Credit / liabilities</h2>
|
||||||
|
<div class="value negative animate-counter" data-target="{{$d.CreditCents}}" data-prefix="€">€0.00</div>
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">outstanding balance</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Historical chart -->
|
||||||
|
{{if $d.History}}
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
|
<h2>Net worth over time</h2>
|
||||||
|
<span style="font-size:11px; color:var(--text3);">cumulative · all time</span>
|
||||||
|
</div>
|
||||||
|
<canvas id="nw-chart" height="220"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const labels = [{{range $d.History}}"{{.Month}}",{{end}}];
|
||||||
|
const data = [{{range $d.History}}{{.NetCents}},{{end}}];
|
||||||
|
const ctx = document.getElementById('nw-chart').getContext('2d');
|
||||||
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
const accent = getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#6979f8';
|
||||||
|
const green = getComputedStyle(document.documentElement).getPropertyValue('--green').trim() || '#4ade80';
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Net Worth (€)',
|
||||||
|
data: data.map(v => v / 100),
|
||||||
|
borderColor: accent,
|
||||||
|
backgroundColor: isDark ? 'rgba(105,121,248,0.08)' : 'rgba(67,85,232,0.06)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35,
|
||||||
|
pointRadius: data.length > 24 ? 0 : 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
borderWidth: 2,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
interaction: { intersect: false, mode: 'index' },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: ctx => '€' + ctx.parsed.y.toLocaleString('pt-PT', {minimumFractionDigits:2})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.05)' },
|
||||||
|
ticks: { color: isDark ? '#5c6585' : '#9fa8c7', maxTicksLimit: 12 }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.05)' },
|
||||||
|
ticks: {
|
||||||
|
color: isDark ? '#5c6585' : '#9fa8c7',
|
||||||
|
callback: v => '€' + (v / 1000).toFixed(0) + 'k'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{else}}
|
||||||
|
<div class="card empty-state animate-on-scroll">
|
||||||
|
<div style="font-size:48px; margin-bottom:16px;">📊</div>
|
||||||
|
<h3>No transaction history yet</h3>
|
||||||
|
<p style="margin-bottom:20px;">Import some transactions to see your net worth over time.</p>
|
||||||
|
<a href="/import" class="btn btn-primary">Import transactions →</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
Loading…
x
Reference in New Issue
Block a user