Gonçalo Rodrigues ac073acad9 feat(finance): Layer 2 — property equity integrated into Net Worth (#30)
* feat(finance): Layer 2 — property equity flows into Net Worth

- NetWorthData gains PropertyValueCents, LoanBalanceCents, PropertyEquityCents
- NetWorth handler fetches properties + loans; adds equity to current snapshot
  and uses amortisation formula to compute historical loan balances per month,
  so the chart reflects how equity grew as loans were paid down
- Dashboard NetWorthCents now includes property equity
- loanBalanceAt() helper: B_n = P*(1+r)^n - (M/r)*((1+r)^n - 1)
- networth.html: inline breakdown row in hero card (cash / portfolio / equity),
  new "Property equity" breakdown card (value − loans), chart gains a dashed
  red "Loans outstanding" line when properties are present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(property): resolve template, image pull, and build issues

- Fix parseTmpl missing base.html causing "base.html is undefined" error
- Change imagePullPolicy to IfNotPresent for local k3d dev workflow
- Add SERVICE_NAME to Makefile so make build-deploy uses correct image name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:01:55 +01:00

171 lines
6.6 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{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 style="margin-top:14px; display:flex; flex-wrap:wrap; gap:18px; font-size:13px; color:var(--text3);">
<span>💵 Cash <strong style="color:var(--text2);">€{{cents $d.CashCents}}</strong></span>
<span>📈 Portfolio <strong style="color:var(--text2);">€{{cents $d.PortfolioCents}}</strong></span>
{{if $d.PropertyValueCents}}
<span>🏠 Property equity <strong style="color:var(--text2);">€{{cents $d.PropertyEquityCents}}</strong></span>
{{end}}
{{if $d.CreditCents}}
<span>💳 Credit <strong style="color:var(--red);">-€{{cents $d.CreditCents}}</strong></span>
{{end}}
</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.PropertyValueCents}}
<div class="card value-card animate-on-scroll">
<h2>Property equity</h2>
<div class="value animate-counter {{if lt $d.PropertyEquityCents 0}}negative{{else}}positive{{end}}"
data-target="{{$d.PropertyEquityCents}}" data-prefix="€">€0.00</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">
€{{cents $d.PropertyValueCents}} value €{{cents $d.LoanBalanceCents}} loans
</p>
</div>
{{end}}
{{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 netData = [{{range $d.History}}{{.NetCents}},{{end}}];
const cashData = [{{range $d.History}}{{.AssetCents}},{{end}}]; // assets (cash+portfolio+property)
const liabData = [{{range $d.History}}{{.LiabCents}},{{end}}]; // loan balances
const hasProperty = {{if $d.PropertyValueCents}}true{{else}}false{{end}};
const ctx = document.getElementById('nw-chart').getContext('2d');
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const style = getComputedStyle(document.documentElement);
const accent = style.getPropertyValue('--accent').trim() || '#6979f8';
const green = style.getPropertyValue('--green').trim() || '#4ade80';
const red = style.getPropertyValue('--red').trim() || '#f87171';
const teal = '#14b8a6';
const pts = netData.length > 24 ? 0 : 3;
const datasets = [{
label: 'Net Worth',
data: netData.map(v => v / 100),
borderColor: accent,
backgroundColor: isDark ? 'rgba(105,121,248,0.07)' : 'rgba(67,85,232,0.05)',
fill: true,
tension: 0.35,
pointRadius: pts,
pointHoverRadius: 5,
borderWidth: 2.5,
order: 0,
}];
if (hasProperty) {
datasets.push({
label: 'Loans outstanding',
data: liabData.map(v => -(v / 100)),
borderColor: red,
backgroundColor: isDark ? 'rgba(248,113,113,0.06)' : 'rgba(239,68,68,0.04)',
fill: true,
tension: 0.35,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 1.5,
borderDash: [4, 3],
order: 1,
});
}
new Chart(ctx, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
interaction: { intersect: false, mode: 'index' },
plugins: {
legend: { display: hasProperty, labels: { color: isDark ? '#8892b0' : '#64748b', boxWidth: 12, font: { size: 12 } } },
tooltip: {
callbacks: {
label: c => c.dataset.label + ': €' + Math.abs(c.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}}