Merge pull request #10 from GoncaloRodri/feature/phase4-net-worth

Feature/phase4 net worth
This commit is contained in:
Gonçalo Rodrigues 2026-06-13 16:54:52 +01:00 committed by GitHub
commit 2150745d1c
5 changed files with 275 additions and 9 deletions

View File

@ -116,6 +116,7 @@ var (
portfolioTmpl = parseTmpl("templates/base.html", "templates/portfolio.html")
sharingTmpl = parseTmpl("templates/base.html", "templates/sharing.html")
goalsTmpl = parseTmpl("templates/base.html", "templates/goals.html")
networthTmpl = parseTmpl("templates/base.html", "templates/networth.html")
)
type authInfo struct {
@ -324,7 +325,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
// disposable income = income - fixed recurring
disposableIncome := thisMonthIncome - totalFixedCents
// deduct committed goal contributions from disposable
// deduct committed goal contributions from disposable and add to fixed costs list
committedGoalsCents := int64(0)
if goals, err := h.store.getGoals(ctx, a.UserID); err == nil {
now2 := time.Now()
@ -340,10 +341,17 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
if ml < 1 {
ml = 1
}
committedGoalsCents += remaining / ml
monthly := remaining / ml
committedGoalsCents += monthly
recurringExpenses = append(recurringExpenses, RecurringExpense{
Category: g.Name,
MonthlyCents: monthly,
IsGoal: true,
})
}
}
disposableIncome -= committedGoalsCents
totalCommittedCents := totalFixedCents + committedGoalsCents
// variable spend so far this month (non-fixed categories, expenses only)
variableSpent := int64(0)
@ -450,12 +458,14 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
RecurringExpenses: recurringExpenses,
BankShouldBe: bankShouldBe,
SafetyBufferCents: safetyBuffer,
TotalCommittedCents: totalCommittedCents,
SavingsRatePct: savingsRatePct,
LastMonthSavingsRatePct: lastMonthSavingsRatePct,
PortfolioValueCents: portfolioValueCents,
PortfolioPCLCents: portfolioPCLCents,
PortfolioHoldings: portfolioHoldings,
PortfolioPricesAvailable: portfolioPricesAvailable,
NetWorthCents: portfolioValueCents + running,
})
}
@ -1535,6 +1545,94 @@ func parseFloat(s string) float64 {
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) {
mux.HandleFunc("GET /{$}", h.Dashboard)
mux.HandleFunc("GET /transactions", h.Transactions)
@ -1554,6 +1652,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /portfolio", h.Portfolio)
mux.HandleFunc("GET /goals", h.Goals)
mux.HandleFunc("POST /goals", h.Goals)
mux.HandleFunc("GET /networth", h.NetWorth)
mux.HandleFunc("GET /sharing", h.Sharing)
mux.HandleFunc("POST /sharing", h.Sharing)
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)

View File

@ -115,6 +115,7 @@ var FixedCategories = map[string]bool{
type RecurringExpense struct {
Category string
MonthlyCents int64
IsGoal bool // true when this entry comes from a committed goal
}
type DashboardData struct {
@ -140,8 +141,9 @@ type DashboardData struct {
MonthSpentPct int // % of disposable already spent
RecurringExpenses []RecurringExpense
BankShouldBe int64 // sum of upcoming fixed costs + safety buffer
BankShouldBe int64
SafetyBufferCents int64
TotalCommittedCents int64 // sum of all fixed costs + committed goals
SavingsRatePct int // savings / income * 100 this month
LastMonthSavingsRatePct int
@ -150,6 +152,8 @@ type DashboardData struct {
PortfolioPCLCents int64
PortfolioHoldings []Holding
PortfolioPricesAvailable bool
NetWorthCents int64
}
type PeriodSummary struct {
@ -215,6 +219,31 @@ type SharingUser struct {
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.
type GoalType string

View File

@ -400,6 +400,7 @@
<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="/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>
<div class="nav-spacer"></div>
<span class="nav-email">{{.Email}}</span>

View File

@ -55,6 +55,13 @@
{{end}}
</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">
<h2>Portfolio today</h2>
{{if $d.PortfolioHoldings}}
@ -236,10 +243,14 @@
{{$color := index $d.CategoryColors .Category}}
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--border);">
<div style="display:flex; align-items:center; gap:10px;">
{{if $color}}<span style="width:9px; height:9px; border-radius:50%; background:{{$color}}; flex-shrink:0; display:inline-block;"></span>{{end}}
{{if .IsGoal}}
<span style="width:9px; height:9px; border-radius:50%; background:var(--accent); flex-shrink:0; display:inline-block;"></span>
{{else if $color}}
<span style="width:9px; height:9px; border-radius:50%; background:{{$color}}; flex-shrink:0; display:inline-block;"></span>
{{end}}
<div>
<div style="font-size:13px; font-weight:500; color:var(--text);">{{.Category}}</div>
<div style="font-size:11px; color:var(--text3);">committed monthly cost</div>
<div style="font-size:11px; color:var(--text3);">{{if .IsGoal}}committed goal{{else}}recurring expense{{end}}</div>
</div>
</div>
<div style="text-align:right;">
@ -249,8 +260,9 @@
</div>
{{end}}
<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 fixed</span>
<span style="font-size:15px; font-weight:600; color:var(--red);"> €{{cents $d.BankShouldBe}}</span>
<span style="font-size:13px; font-weight:500; color:var(--text);">Total committed</span>
<span class="animate-counter" style="font-size:15px; font-weight:600; color:var(--red);"
data-target="{{$d.TotalCommittedCents}}" data-prefix="€">€0</span>
</div>
</div>
</div>

View 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}}