Gonçalo Rodrigues 995c6d89d6 feat: phase 6 — dashboard alerts and nudges
Adds server-computed alert banners to the dashboard:
- Budget exceeded (red): when a category spend is over 100% of budget
- Budget pace warning (amber): 80%+ of budget used but <80% of month elapsed
- Goal deadline risk (amber): avg savings rate too low to hit a goal on time
- Spend pace warning (amber): disposable spending is 20%+ ahead of month progress

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 17:03:12 +01:00

285 lines
14 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>Dashboard</h1>
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
</div>
{{if $d.Alerts}}
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:16px;">
{{range $d.Alerts}}
<div style="display:flex; align-items:flex-start; gap:10px; padding:12px 16px; border-radius:10px; font-size:13px;
{{if eq .Level "danger"}}background:rgba(248,113,113,0.08); border:1px solid rgba(248,113,113,0.25); color:var(--red);
{{else if eq .Level "warn"}}background:rgba(245,158,11,0.08); border:1px solid rgba(245,158,11,0.25); color:#f59e0b;
{{else}}background:rgba(105,121,248,0.08); border:1px solid rgba(105,121,248,0.25); color:var(--accent);{{end}}">
<span style="flex-shrink:0; font-size:15px;">{{if eq .Level "danger"}}🔴{{else if eq .Level "warn"}}⚠{{else}}{{end}}</span>
<span>{{.Message}}</span>
</div>
{{end}}
</div>
{{end}}
<!-- HERO: available to spend -->
<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;">
Available to spend this month
<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;">
income fixed costs spent so far
</span>
</div>
<div style="display:flex; align-items:baseline; gap:20px; flex-wrap:wrap; margin-bottom:16px;">
<div class="animate-counter {{if lt $d.AvailableToSpend 0}}negative{{else}}positive{{end}}"
style="font-size:42px; font-weight:500; letter-spacing:-1.5px; line-height:1;"
data-target="{{$d.AvailableToSpend}}" data-prefix="€">€0.00</div>
<div style="font-size:13px; color:var(--text2);">
of <span style="color:var(--text); font-weight:500;" class="animate-counter" data-target="{{$d.DisposableIncome}}" data-prefix="€">€0.00</span> disposable
&nbsp;·&nbsp; <span style="color:var(--text2);">{{$d.MonthSpentPct}}% used</span>
</div>
</div>
<div style="background:var(--bg3); border-radius:99px; height:6px; overflow:hidden; margin-bottom:6px;">
<div style="height:100%; border-radius:99px; width:{{$d.MonthSpentPct}}%;
background:{{if gt $d.MonthSpentPct 90}}var(--red){{else if gt $d.MonthSpentPct 70}}#f59e0b{{else}}var(--green){{end}};
transition:width 1s ease;"></div>
</div>
<div style="display:flex; justify-content:space-between;">
<span style="font-size:11px; color:var(--text3);">Month progress: {{$d.MonthProgressPct}}%</span>
<span style="font-size:11px; color:var(--text3);">Spent: {{$d.MonthSpentPct}}%</span>
</div>
</div>
<!-- 3 diagnostic cards -->
<div class="grid" style="margin-bottom:16px;">
<div class="card value-card animate-on-scroll">
<h2>Bank balance should be</h2>
<div class="value animate-counter" data-target="{{$d.BankShouldBe}}" data-prefix="€" style="color:var(--text);">€0.00</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">upcoming fixed + safety buffer</p>
</div>
<div class="card value-card animate-on-scroll">
<h2>Savings rate</h2>
<div class="value {{if gt $d.SavingsRatePct 0}}positive{{else}}negative{{end}}">{{$d.SavingsRatePct}}%</div>
{{if $d.LastMonthSavingsRatePct}}
<p style="font-size:12px; margin-top:6px; {{if gt $d.SavingsRatePct $d.LastMonthSavingsRatePct}}color:var(--green){{else}}color:var(--red){{end}};">
{{if gt $d.SavingsRatePct $d.LastMonthSavingsRatePct}}↑{{else}}↓{{end}} vs last month ({{$d.LastMonthSavingsRatePct}}%)
</p>
{{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}}
<div class="value animate-counter" data-target="{{$d.PortfolioValueCents}}" data-prefix="€" style="color:var(--text);">€0.00</div>
{{if $d.PortfolioPricesAvailable}}
<p style="font-size:12px; margin-top:6px; {{if ge $d.PortfolioPCLCents 0}}color:var(--green){{else}}color:var(--red){{end}};">
{{if ge $d.PortfolioPCLCents 0}}+{{else}}{{end}}€{{cents (centsAbs $d.PortfolioPCLCents)}} total P&L
</p>
{{else}}
<p style="font-size:12px; color:var(--text3); margin-top:6px;">cost basis · prices unavailable</p>
{{end}}
{{else}}
<div class="value" style="color:var(--text3); font-size:18px;">No trades yet</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;"><a href="/import" style="color:var(--accent);">Import trades →</a></p>
{{end}}
</div>
</div>
<!-- Bank math + stocks -->
<div class="grid-2" style="margin-bottom:16px;">
<div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2>What should be in your bank</h2>
<span style="font-size:11px; color:var(--text3);">right now</span>
</div>
{{if $d.RecurringExpenses}}
<div style="display:flex; flex-direction:column;">
{{range $d.RecurringExpenses}}
{{$color := index $d.CategoryColors .Category}}
<div style="display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid var(--border);">
<span style="font-size:13px; color:var(--text2); display:flex; align-items:center; gap:7px;">
{{if $color}}<span style="width:7px;height:7px;border-radius:50%;background:{{$color}};display:inline-block;"></span>{{end}}
{{.Category}}
</span>
<span style="font-size:13px; font-weight:500; color:var(--red);"> €{{cents .MonthlyCents}}</span>
</div>
{{end}}
{{if $d.SafetyBufferCents}}
<div style="display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid var(--border);">
<span style="font-size:13px; color:var(--text2);">Safety buffer (2 weeks)</span>
<span style="font-size:13px; font-weight:500; color:var(--red);"> €{{cents $d.SafetyBufferCents}}</span>
</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);">Minimum recommended</span>
<span class="animate-counter positive" style="font-size:16px; font-weight:600;"
data-target="{{$d.BankShouldBe}}" data-prefix="€">€0.00</span>
</div>
</div>
{{else}}
<div class="empty-state" style="padding:24px;">
<p>No recurring expenses detected yet.<br>Import a few months of transactions.</p>
</div>
{{end}}
</div>
<div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2>Stocks at a glance</h2>
<a href="/portfolio" style="font-size:12px; color:var(--text3);">→ portfolio</a>
</div>
{{if $d.PortfolioHoldings}}
<div style="display:flex; flex-direction:column;">
{{range $d.PortfolioHoldings}}
<div style="display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border);">
<div>
<div style="font-size:13px; font-weight:600; color:var(--text);">{{.Name}}</div>
<div style="font-size:11px; color:var(--text3);">{{printf "%.4f" .SharesOwned}} shares</div>
</div>
<div style="text-align:right;">
{{if $d.PortfolioPricesAvailable}}
<div style="font-size:13px; font-weight:500; color:var(--text);">€{{cents .CurrentValueCents}}</div>
<div style="font-size:12px; {{if ge .UnrealizedPCLCents 0}}color:var(--green){{else}}color:var(--red){{end}};">
{{pctSign .UnrealizedPCLPct}}{{printf "%.1f" .UnrealizedPCLPct}}%
</div>
{{else}}
<div style="font-size:13px; font-weight:500; color:var(--text);">€{{cents .TotalCostCents}}</div>
<div style="font-size:11px; color:var(--text3);">cost basis</div>
{{end}}
</div>
</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{{if not $d.PortfolioPricesAvailable}} invested{{end}}</span>
<span class="animate-counter" style="font-size:15px; font-weight:600; color:var(--text);"
data-target="{{$d.PortfolioValueCents}}" data-prefix="€">€0.00</span>
</div>
</div>
{{else}}
<div class="empty-state" style="padding:24px;">
<p>No holdings yet.<br><a href="/import" style="color:var(--accent);">Import trades →</a></p>
</div>
{{end}}
</div>
</div>
<!-- Budget health + recent activity -->
<div class="grid-2">
{{if $d.CategoryBudgets}}
<div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2>Budget health</h2>
<a href="/categories" style="font-size:12px; color:var(--text3);">→ categories</a>
</div>
<div style="display:flex; flex-direction:column; gap:10px;">
{{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; margin-bottom:5px;">
<span style="font-size:12px; color:var(--text2); display:flex; align-items:center; gap:6px;">
{{if $color}}<span style="width:7px;height:7px;border-radius:50%;background:{{$color}};display:inline-block;"></span>{{end}}
{{$cat}}
</span>
<span style="font-size:11px; {{if $over}}color:var(--red); font-weight:600;{{else}}color:var(--text3);{{end}}">
{{$pct}}%{{if $over}} ⚠{{end}}
</span>
</div>
<div style="background:var(--bg3); border-radius:99px; height:5px; overflow:hidden;">
<div style="height:100%; border-radius:99px; width:{{$pct}}%;
background:{{if $over}}var(--red){{else if $color}}{{$color}}{{else}}var(--accent){{end}};
transition:width 1s ease;"></div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
<div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2>Recent activity</h2>
<a href="/transactions" style="font-size:12px; color:var(--text3);">→ all transactions</a>
</div>
{{if $d.RecentTxns}}
<div style="display:flex; flex-direction:column;">
{{range $d.RecentTxns}}
{{$color := index $d.CategoryColors .Category}}
<div style="display:flex; align-items:center; justify-content:space-between; padding:9px 0; border-bottom:1px solid var(--border);">
<div style="display:flex; align-items:center; gap:10px; min-width:0;">
<div style="width:8px; height:8px; border-radius:50%; flex-shrink:0;
background:{{if $color}}{{$color}}{{else}}var(--text3){{end}};"></div>
<div style="min-width:0;">
<div style="font-size:13px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:180px;">{{.Description}}</div>
<div style="font-size:11px; color:var(--text3);">{{dateShort .Date}}</div>
</div>
</div>
<div style="font-size:13px; font-weight:500; white-space:nowrap; margin-left:12px;
{{if lt .AmountCents 0}}color:var(--red){{else}}color:var(--green){{end}};">
{{if lt .AmountCents 0}}{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state" style="padding:24px;">
No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a>
</div>
{{end}}
</div>
</div>
{{if $d.RecurringExpenses}}
<div class="card animate-on-scroll" style="margin-top:16px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2>Fixed costs</h2>
<span style="font-size:11px; color:var(--text3);">auto-detected · 3-month average</span>
</div>
<div style="display:flex; flex-direction:column;">
{{range $d.RecurringExpenses}}
{{$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 .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);">{{if .IsGoal}}committed goal{{else}}recurring expense{{end}}</div>
</div>
</div>
<div style="text-align:right;">
<div style="font-size:14px; font-weight:600; color:var(--red);"> €{{cents .MonthlyCents}}</div>
<div style="font-size:11px; color:var(--text3);">/ month</div>
</div>
</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 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>
{{end}}
{{end}}