UI — full dark/light theme system: - CSS custom-property token system (--bg, --surface, --accent, --green, --red, etc.) with complete light-mode overrides via [data-theme="light"] - Sticky frosted-glass nav with animated brand icon, theme toggle persisted to localStorage, respects prefers-color-scheme on first visit - Cards with layered shadows, glass backdrop-filter, shimmer accent stripe on value cards - Glowing category color dots, colored P&L badges, budget bars with glow effect - All Chart.js instances use CSS-variable-aware grid/text colours - Scroll-reveal animations and animated money counters on every KPI card - 3-D donut portfolio chart recoloured to match palette; hover lifts the hovered slice - Accounts page shows type emoji icons; delete removes row in-place - Sharing page search dropdown themed with var() colours - Import preview: colour-coded left border on category select driven by category colour - Projections: second KPI card (monthly avg) + pace bars per category Seed data (seed.go): - SeedAdmin() runs in a goroutine at startup; idempotent (skips if transactions exist) - Resolves admin user ID via internal users service GET /admin/users?search=<email> (SEED_USER_EMAIL env var, defaults to admin@homelab.local) - Seeds 4 accounts (CGD Checking, CGD Savings, Visa Credit, Trade Republic) - Seeds 14 categories with colours and monthly budgets - Seeds ~65 realistic Portuguese household transactions spread across 6 months - Seeds 7 ETF buy trades across VWCE, SXR8 (S&P 500), EUNL (MSCI World) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
218 lines
8.1 KiB
HTML
218 lines
8.1 KiB
HTML
{{define "content"}}
|
||
{{$d := .}}
|
||
{{$change := sub $d.ThisMonth.TotalCents $d.LastMonth.TotalCents}}
|
||
|
||
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
||
<h1>Dashboard</h1>
|
||
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
||
</div>
|
||
|
||
<!-- KPI cards -->
|
||
<div class="grid">
|
||
<div class="card value-card animate-on-scroll">
|
||
<h2>Net This Month</h2>
|
||
<div class="value {{if lt $d.ThisMonth.TotalCents 0}}negative{{else}}positive{{end}} animate-counter"
|
||
data-target="{{$d.ThisMonth.TotalCents}}" data-prefix="€">€0.00</div>
|
||
</div>
|
||
<div class="card value-card animate-on-scroll">
|
||
<h2>Income</h2>
|
||
<div class="value positive animate-counter"
|
||
data-target="{{$d.ThisMonthIncome}}" data-prefix="€">€0.00</div>
|
||
</div>
|
||
<div class="card value-card animate-on-scroll">
|
||
<h2>Expenses</h2>
|
||
<div class="value negative animate-counter"
|
||
data-target="{{$d.ThisMonthExpense}}" data-prefix="€">€0.00</div>
|
||
</div>
|
||
<div class="card value-card animate-on-scroll">
|
||
<h2>vs Last Month</h2>
|
||
<div class="value {{if lt $change 0}}negative{{else}}positive{{end}} animate-counter"
|
||
data-target="{{$change}}" data-prefix="€">€0.00</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Charts -->
|
||
<div class="grid-2">
|
||
<div class="card animate-on-scroll">
|
||
<h2>Spending by Category — This Month</h2>
|
||
{{if $d.ThisMonth.ByCategory}}
|
||
<div style="position:relative; padding-top:8px;">
|
||
<canvas id="thisMonthChart" height="220"></canvas>
|
||
</div>
|
||
{{else}}
|
||
<div class="empty-state" style="padding:32px;">No spending data this month.</div>
|
||
{{end}}
|
||
</div>
|
||
<div class="card animate-on-scroll">
|
||
<h2>Balance Trend — 90 Days</h2>
|
||
{{if $d.BalanceTrend}}
|
||
<div style="position:relative; padding-top:8px;">
|
||
<canvas id="balanceChart" height="220"></canvas>
|
||
</div>
|
||
{{else}}
|
||
<div class="empty-state" style="padding:32px;">No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a></div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Budget progress -->
|
||
{{if $d.CategoryBudgets}}
|
||
<div class="card animate-on-scroll">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:18px;">
|
||
<h2>Budget vs Actual — This Month</h2>
|
||
<a href="/categories" class="btn btn-outline btn-sm">Manage budgets</a>
|
||
</div>
|
||
<div style="display:flex; flex-direction:column; gap:16px;">
|
||
{{range $cat, $budget := $d.CategoryBudgets}}
|
||
{{$spent := index $d.ThisMonth.ByCategory $cat}}
|
||
{{$spentAbs := centsAbs $spent}}
|
||
{{$color := index $d.CategoryColors $cat}}
|
||
{{$over := isOver $spentAbs $budget}}
|
||
{{$pct := clampPct $spentAbs $budget}}
|
||
<div>
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:7px;">
|
||
<span style="font-size:13.5px; font-weight:500; display:flex; align-items:center; gap:7px;">
|
||
{{if $color}}<span style="width:9px;height:9px;border-radius:50%;background:{{$color}};display:inline-block;box-shadow:0 0 6px {{$color}}66;"></span>{{end}}
|
||
{{$cat}}
|
||
</span>
|
||
<span style="font-size:12.5px; {{if $over}}color:var(--red);font-weight:600;{{else}}color:var(--text2);{{end}}">
|
||
€{{cents $spentAbs}} / €{{cents $budget}}
|
||
{{if $over}} ⚠ over budget{{end}}
|
||
</span>
|
||
</div>
|
||
<div style="background:var(--bg3); border-radius:8px; height:7px; overflow:hidden;">
|
||
<div style="height:100%; border-radius:8px; width:{{$pct}}%; transition:width 1s ease;
|
||
background:{{if $over}}var(--red){{else if $color}}{{$color}}{{else}}var(--accent){{end}};
|
||
box-shadow:{{if $over}}0 0 8px var(--red){{else if $color}}0 0 6px {{$color}}88{{else}}0 0 6px var(--accent-glow){{end}};"></div>
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
<!-- Recent transactions -->
|
||
<div class="card animate-on-scroll">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:14px;">
|
||
<h2>Recent Transactions</h2>
|
||
<a href="/transactions" class="btn btn-outline btn-sm">View all</a>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Date</th>
|
||
<th>Description</th>
|
||
<th>Category</th>
|
||
<th class="text-right">Amount</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{{range $d.RecentTxns}}
|
||
{{$color := index $d.CategoryColors .Category}}
|
||
<tr>
|
||
<td style="white-space:nowrap; color:var(--text2);">{{dateShort .Date}}</td>
|
||
<td style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
|
||
<td>
|
||
<span class="badge" style="
|
||
background:{{if $color}}{{$color}}18{{else}}var(--bg3){{end}};
|
||
color:{{if $color}}{{$color}}{{else}}var(--text2){{end}};
|
||
border:1px solid {{if $color}}{{$color}}33{{else}}var(--border2){{end}};">
|
||
{{if $color}}<span class="category-dot" style="background:{{$color}};box-shadow:0 0 4px {{$color}}88;"></span>{{end}}
|
||
{{.Category}}
|
||
</span>
|
||
</td>
|
||
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="font-weight:600; white-space:nowrap;">
|
||
{{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
|
||
</td>
|
||
</tr>
|
||
{{else}}
|
||
<tr>
|
||
<td colspan="4" class="text-center text-muted" style="padding:36px;">
|
||
No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a>
|
||
</td>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
|
||
const isDark = () => document.documentElement.getAttribute('data-theme') === 'dark';
|
||
|
||
function gridColor() { return isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'; }
|
||
function textColor() { return isDark() ? '#5c6585' : '#8a92b0'; }
|
||
|
||
{{if $d.ThisMonth.ByCategory}}
|
||
const catLabels = {{jsonKeys $d.ThisMonth.ByCategory}};
|
||
const catData = {{jsonVals $d.ThisMonth.ByCategory}};
|
||
const resolvedColors = catLabels.map(k => catColors[k] || '#6979f8');
|
||
|
||
new Chart(document.getElementById('thisMonthChart'), {
|
||
type: 'bar',
|
||
data: {
|
||
labels: catLabels,
|
||
datasets: [{
|
||
data: catData.map(v => Math.abs(v) / 100),
|
||
backgroundColor: resolvedColors.map(c => c + '99'),
|
||
borderColor: resolvedColors,
|
||
borderWidth: 1.5,
|
||
borderRadius: 6,
|
||
borderSkipped: false,
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
animation: { duration: 900, easing: 'easeOutQuart' },
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
y: { beginAtZero: true, grid: { color: gridColor() }, ticks: { color: textColor(), callback: v => '€' + v } },
|
||
x: { grid: { display: false }, ticks: { color: textColor() } }
|
||
}
|
||
}
|
||
});
|
||
{{end}}
|
||
|
||
{{if $d.BalanceTrend}}
|
||
const ctx = document.getElementById('balanceChart').getContext('2d');
|
||
const grad = ctx.createLinearGradient(0, 0, 0, 260);
|
||
grad.addColorStop(0, 'rgba(105,121,248,0.30)');
|
||
grad.addColorStop(0.6, 'rgba(105,121,248,0.08)');
|
||
grad.addColorStop(1, 'rgba(105,121,248,0.00)');
|
||
|
||
new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: [{{range $d.BalanceTrend}}"{{dateShort .Date}}",{{end}}],
|
||
datasets: [{
|
||
label: 'Balance (€)',
|
||
data: [{{range $d.BalanceTrend}}{{div .Cents 100}},{{end}}],
|
||
borderColor: '#6979f8',
|
||
backgroundColor: grad,
|
||
fill: true,
|
||
tension: 0.42,
|
||
pointRadius: 0,
|
||
pointHoverRadius: 5,
|
||
pointBackgroundColor: '#fff',
|
||
pointBorderColor: '#6979f8',
|
||
pointBorderWidth: 2,
|
||
borderWidth: 2.5,
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
animation: { duration: 1100, easing: 'easeOutQuart' },
|
||
plugins: { legend: { display: false } },
|
||
scales: {
|
||
y: { beginAtZero: false, grid: { color: gridColor() }, ticks: { color: textColor(), callback: v => '€' + v } },
|
||
x: { grid: { display: false }, ticks: { color: textColor(), maxTicksLimit: 7 } }
|
||
},
|
||
interaction: { intersect: false, mode: 'index' }
|
||
}
|
||
});
|
||
{{end}}
|
||
</script>
|
||
{{end}}
|