Gonçalo Rodrigues 5f60d963a0 feat(finance): transaction-backed goals + interactive waterfall
Goals are now funded entirely through tagged transactions — no more
manually-maintained saved_cents. Free cash waterfall (income → living →
goals → free cash) is the single source of truth for where money goes.

Core changes:
- Transaction.GoalID field links outflows to goals; SavedCents is derived
  via MongoDB aggregation (getGoalFundedCentsAll) instead of stored
- Waterfall on dashboard and goals page splits outflows into living vs
  goal-funded using GoalID presence
- ImpactOnDisposable fixed: uses income−living−monthlyCents instead of
  waterfallFreeCash−monthlyCents (was double-subtracting goal spend)
- avgMonthlySavings fixed: divides by positive-saving months only, and
  uses year+month key to avoid Dec cross-year collision

Interactive waterfall drill-down:
- Click Income / Living / Goals rows to expand category breakdown
- Click a category to reveal individual transactions inline
- All rendered server-side (instant, no extra API call)
- New WaterfallRow type + IncomeCats/LivingCats/IncomeCatTxns/LivingCatTxns
  on DashboardData

Goals page:
- Summary cards switched from heuristic disposable/committed to waterfall
- Each goal card shows funding history (last 5 tagged transactions)
- "Fund this goal" button links to /transactions?fund_goal=<id>

Transactions page:
- Add Transaction modal has goal picker dropdown
- submitAdd() includes goal_id in POST body
- Auto-opens modal pre-selected when arriving from goals page

Seed:
- seedGoalTransactions() back-fills tagged contributions for all 4 demo
  goals (Emergency fund, House down payment, Japan trip, MacBook Pro)
- Idempotent — skips if goal-tagged transactions already exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 22:18:47 +01:00

409 lines
20 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>{{$d.T.Get "dashboard.title"}}</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: interactive cash flow waterfall -->
<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:4px;">
{{$d.T.Get "dashboard.waterfall.title"}}
</div>
<!-- Income row -->
{{if $d.IncomeCats}}
<div class="wf-section">
<button class="wf-row" onclick="wfToggle('income')" aria-expanded="false">
<span class="wf-chevron" id="wf-chev-income"></span>
<span class="wf-row-label">{{$d.T.Get "dashboard.waterfall.income"}}</span>
<span class="wf-row-amt positive">+€{{cents $d.WaterfallIncome}}</span>
</button>
<div class="wf-detail" id="wf-income" style="display:none;">
{{range $i, $row := $d.IncomeCats}}
<div class="wf-cat-section">
<button class="wf-cat-row" onclick="wfToggleCat('ic{{$i}}')">
{{if $row.Color}}<span class="cat-dot" style="background:{{$row.Color}};"></span>{{end}}
<span style="flex:1; font-size:12px;">{{$row.Name}}</span>
<span class="wf-cat-chev" id="wf-cc-ic{{$i}}"></span>
<span style="font-size:12px; font-weight:500; color:var(--green);">+€{{cents $row.Cents}}</span>
</button>
<div class="wf-txn-list" id="wf-ic{{$i}}" style="display:none;">
{{range index $d.IncomeCatTxns $row.Name}}
<div class="wf-txn-row">
<span class="wf-txn-date">{{dateShort .Date}}</span>
<span class="wf-txn-desc">{{.Description}}</span>
<span class="wf-txn-amt positive">+€{{cents .AmountCents}}</span>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
</div>
{{else}}
<div class="wf-row" style="cursor:default;">
<span class="wf-row-label" style="padding-left:20px;">{{$d.T.Get "dashboard.waterfall.income"}}</span>
<span class="wf-row-amt positive">+€{{cents $d.WaterfallIncome}}</span>
</div>
{{end}}
<!-- Living expenses row -->
{{if $d.LivingCats}}
<div class="wf-section">
<button class="wf-row" onclick="wfToggle('living')" aria-expanded="false">
<span class="wf-chevron" id="wf-chev-living"></span>
<span class="wf-row-label">{{$d.T.Get "dashboard.waterfall.living"}}</span>
<span class="wf-row-amt" style="color:var(--red);">−€{{cents $d.WaterfallLiving}}</span>
</button>
<div class="wf-detail" id="wf-living" style="display:none;">
{{range $i, $row := $d.LivingCats}}
<div class="wf-cat-section">
<button class="wf-cat-row" onclick="wfToggleCat('lc{{$i}}')">
{{if $row.Color}}<span class="cat-dot" style="background:{{$row.Color}};"></span>{{end}}
<span style="flex:1; font-size:12px;">{{$row.Name}}</span>
<span class="wf-cat-chev" id="wf-cc-lc{{$i}}"></span>
<span style="font-size:12px; font-weight:500; color:var(--red);">−€{{cents $row.Cents}}</span>
</button>
<div class="wf-txn-list" id="wf-lc{{$i}}" style="display:none;">
{{range index $d.LivingCatTxns $row.Name}}
<div class="wf-txn-row">
<span class="wf-txn-date">{{dateShort .Date}}</span>
<span class="wf-txn-desc">{{.Description}}</span>
<span class="wf-txn-amt" style="color:var(--red);">−€{{cents (abs .AmountCents)}}</span>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
</div>
{{else if gt $d.WaterfallLiving 0}}
<div class="wf-row" style="cursor:default;">
<span class="wf-row-label" style="padding-left:20px;">{{$d.T.Get "dashboard.waterfall.living"}}</span>
<span class="wf-row-amt" style="color:var(--red);">−€{{cents $d.WaterfallLiving}}</span>
</div>
{{end}}
<!-- Goal contributions row -->
{{if gt $d.WaterfallGoals 0}}
<div class="wf-section">
{{if $d.DashGoals}}
<button class="wf-row" onclick="wfToggle('goals')" aria-expanded="false">
<span class="wf-chevron" id="wf-chev-goals"></span>
<span class="wf-row-label">{{$d.T.Get "dashboard.waterfall.goals"}}</span>
<span class="wf-row-amt" style="color:var(--accent);">−€{{cents $d.WaterfallGoals}}</span>
</button>
<div class="wf-detail" id="wf-goals" style="display:none;">
{{range $d.DashGoals}}
{{$funded := index $d.GoalFundedThisMonth .ID}}
{{if gt $funded 0}}
<div class="wf-cat-row" style="cursor:default;">
<span style="font-size:11px;">{{if eq .Type "once"}}🎯{{else if eq .Type "deposit"}}🏠{{else if eq .Type "emergency"}}🛡️{{else}}📈{{end}}</span>
<span style="flex:1; font-size:12px;">{{.Name}}</span>
<a href="/goals" style="font-size:11px; color:var(--accent); text-decoration:none; margin-right:8px;">{{$d.T.Get "dashboard.waterfall.goals_link"}}</a>
<span style="font-size:12px; font-weight:500; color:var(--accent);">−€{{cents $funded}}</span>
</div>
{{end}}
{{end}}
</div>
{{else}}
<div class="wf-row" style="cursor:default;">
<span class="wf-row-label" style="padding-left:20px; display:flex; align-items:center; gap:8px;">
{{$d.T.Get "dashboard.waterfall.goals"}}
<a href="/goals" style="font-size:11px; color:var(--accent); text-decoration:none;">{{$d.T.Get "dashboard.waterfall.goals_link"}}</a>
</span>
<span class="wf-row-amt" style="color:var(--accent);">−€{{cents $d.WaterfallGoals}}</span>
</div>
{{end}}
</div>
{{end}}
<!-- Free cash total -->
<div style="display:flex; justify-content:space-between; align-items:center; padding:14px 0 0 0; margin-top:4px; border-top:1px solid var(--border);">
<span style="font-size:14px; font-weight:600; color:var(--text);">{{$d.T.Get "dashboard.waterfall.free_cash"}}</span>
<div class="animate-counter {{if lt $d.WaterfallFreeCash 0}}negative{{else}}positive{{end}}"
style="font-size:32px; font-weight:500; letter-spacing:-1px; line-height:1;"
data-target="{{$d.WaterfallFreeCash}}" data-prefix="€">€0</div>
</div>
<!-- Month progress bar -->
<div style="margin-top:16px; padding-top:14px; border-top:1px solid var(--border);">
<div style="display:flex; justify-content:space-between; font-size:11px; color:var(--text3); margin-bottom:5px;">
<span>{{$d.T.Get "dashboard.waterfall.month_progress"}}</span>
<span>{{$d.MonthProgressPct}}%</span>
</div>
<div style="background:var(--bg3); border-radius:99px; height:4px; overflow:hidden;">
<div style="height:100%; border-radius:99px; width:{{$d.MonthProgressPct}}%;
background:var(--text3); transition:width 1s ease;"></div>
</div>
</div>
</div>
<style>
.wf-section { border-bottom: 1px solid var(--border); }
.wf-section:last-of-type { border-bottom: none; }
.wf-row {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 10px 0;
background: none; border: none; cursor: pointer; text-align: left;
color: var(--text);
}
.wf-row:hover .wf-row-label { color: var(--text); }
.wf-chevron {
font-size: 14px; color: var(--text3); width: 14px; flex-shrink: 0;
transition: transform 0.18s ease; display: inline-block;
transform-origin: center 45%;
}
.wf-chevron.open { transform: rotate(90deg); }
.wf-row-label { flex: 1; font-size: 13px; color: var(--text2); }
.wf-row-amt { font-size: 15px; font-weight: 600; white-space: nowrap; }
.wf-detail { padding: 4px 0 8px 22px; }
.wf-cat-section { }
.wf-cat-row {
display: flex; align-items: center; gap: 7px;
width: 100%; padding: 6px 0;
background: none; border: none; cursor: pointer; text-align: left;
color: var(--text2);
}
.wf-cat-chev {
font-size: 11px; color: var(--text3); width: 10px;
transition: transform 0.15s ease; display: inline-block;
transform-origin: center 45%;
}
.wf-cat-chev.open { transform: rotate(90deg); }
.cat-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.wf-txn-list { padding: 2px 0 6px 14px; }
.wf-txn-row {
display: flex; align-items: center; gap: 8px;
padding: 5px 0; border-bottom: 1px solid var(--border);
font-size: 12px;
}
.wf-txn-row:last-child { border-bottom: none; }
.wf-txn-date { color: var(--text3); white-space: nowrap; min-width: 44px; }
.wf-txn-desc { flex: 1; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wf-txn-amt { white-space: nowrap; font-weight: 500; }
</style>
<script>
function wfToggle(id) {
const detail = document.getElementById('wf-' + id);
const chev = document.getElementById('wf-chev-' + id);
const open = detail.style.display !== 'none';
detail.style.display = open ? 'none' : 'block';
if (chev) chev.classList.toggle('open', !open);
}
function wfToggleCat(id) {
const list = document.getElementById('wf-' + id);
const chev = document.getElementById('wf-cc-' + id);
const open = list.style.display !== 'none';
list.style.display = open ? 'none' : 'block';
if (chev) chev.classList.toggle('open', !open);
}
</script>
<!-- 3 diagnostic cards -->
<div class="grid" style="margin-bottom:16px;">
<div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "dashboard.cards.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}} {{$d.T.Get "dashboard.alerts.vs_last_month_up"}} ({{$d.LastMonthSavingsRatePct}}%)
</p>
{{end}}
</div>
<div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "dashboard.cards.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);">{{$d.T.Get "dashboard.cards.net_worth_link"}}</a></p>
</div>
<div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "dashboard.cards.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;">{{$d.T.Get "dashboard.cards.portfolio_cost_basis"}}</p>
{{end}}
{{else}}
<div class="value" style="color:var(--text3); font-size:18px;">{{$d.T.Get "dashboard.cards.portfolio_no_trades"}}</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;"><a href="/import" style="color:var(--accent);">{{$d.T.Get "dashboard.cards.portfolio_import_link"}}</a></p>
{{end}}
</div>
</div>
<!-- Stocks + spending breakdown -->
<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>{{$d.T.Get "dashboard.stocks.section_title"}}</h2>
<a href="/portfolio" style="font-size:12px; color:var(--text3);">{{$d.T.Get "dashboard.stocks.portfolio_link"}}</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}} {{$d.T.Get "dashboard.stocks.shares_label"}}</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);">{{$d.T.Get "dashboard.stocks.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);">{{$d.T.Get "dashboard.stocks.total_label"}}{{if not $d.PortfolioPricesAvailable}} {{$d.T.Get "dashboard.stocks.total_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>{{$d.T.Get "dashboard.stocks.no_holdings_msg"}}<br><a href="/import" style="color:var(--accent);">{{$d.T.Get "dashboard.stocks.import_link"}}</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>{{$d.T.Get "dashboard.budget_health.section_title"}}</h2>
<a href="/categories" style="font-size:12px; color:var(--text3);">{{$d.T.Get "dashboard.budget_health.categories_link"}}</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>{{$d.T.Get "dashboard.recent.section_title"}}</h2>
<a href="/transactions" style="font-size:12px; color:var(--text3);">{{$d.T.Get "dashboard.recent.all_txns_link"}}</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;">
{{$d.T.Get "dashboard.recent.no_txns_msg"}} <a href="/import" style="color:var(--accent);">{{$d.T.Get "dashboard.recent.import_link"}}</a>
</div>
{{end}}
</div>
</div>
{{if $d.DashGoals}}
<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>{{$d.T.Get "dashboard.goals.section_title"}}</h2>
<a href="/goals" style="font-size:12px; color:var(--text3);">{{$d.T.Get "dashboard.goals.all_goals_link"}}</a>
</div>
<div style="display:flex; flex-direction:column; gap:14px;">
{{range $d.DashGoals}}
<div>
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:6px;">
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:15px;">{{if eq .Type "once"}}🎯{{else if eq .Type "deposit"}}🏠{{else if eq .Type "emergency"}}🛡️{{else}}📈{{end}}</span>
<span style="font-size:13px; font-weight:500; color:var(--text);">{{.Name}}</span>
</div>
<div style="display:flex; align-items:center; gap:12px;">
<span style="font-size:12px; color:var(--text3);">{{.MonthsLeft}}{{$d.T.Get "dashboard.goals.months_left"}}</span>
<span style="font-size:12px; font-weight:600; color:{{if .Feasible}}var(--green){{else}}var(--red){{end}};">{{.ProgressPct}}%</span>
</div>
</div>
<div style="background:var(--bg3); border-radius:99px; height:5px; overflow:hidden;">
<div style="height:100%; border-radius:99px; width:{{.ProgressPct}}%;
background:{{if .Feasible}}var(--green){{else}}var(--accent){{end}};
transition:width 1s ease;"></div>
</div>
<div style="display:flex; justify-content:space-between; margin-top:4px; font-size:11px; color:var(--text3);">
<span>€{{cents .SavedCents}} of €{{cents .TargetCents}}</span>
<span style="color:{{if .Feasible}}var(--green){{else}}var(--red){{end}};">€{{cents .MonthlyCents}}{{$d.T.Get "dashboard.goals.per_month_needed"}}</span>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{end}}