* 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>
171 lines
6.6 KiB
HTML
171 lines
6.6 KiB
HTML
{{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}}
|