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

286 lines
13 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 := .}}
{{if eq $d.Notice "all_duplicates"}}
<div style="background:rgba(245,158,11,0.1); border:1px solid rgba(245,158,11,0.4); border-radius:10px; padding:12px 16px; margin-bottom:20px; display:flex; align-items:center; gap:10px;">
<span style="font-size:1.1rem;"></span>
<span style="font-size:0.9rem;">{{$d.T.Get "transactions.warning_all_dupes"}}</span>
</div>
{{end}}
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
<h1>{{$d.T.Get "transactions.title"}}</h1>
<button class="btn btn-primary" onclick="openAddModal()">{{$d.T.Get "transactions.btn_add"}}</button>
</div>
<div class="card mb-16">
<form method="GET" class="flex flex-wrap" style="gap:10px; align-items:flex-end;">
<div class="form-group" style="margin-bottom:0; min-width:160px;">
<label>{{$d.T.Get "transactions.filter.label_category"}}</label>
<select name="category">
<option value="">{{$d.T.Get "transactions.filter.option_all_cats"}}</option>
{{range $d.Categories}}
<option value="{{.Name}}" {{if eq $.Cat .Name}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="form-group" style="margin-bottom:0; min-width:130px;">
<label>{{$d.T.Get "transactions.filter.label_period"}}</label>
<select name="days">
<option value="">{{$d.T.Get "transactions.filter.option_all_time"}}</option>
<option value="30" {{if eq $.Days "30"}}selected{{end}}>{{$d.T.Get "transactions.filter.option_30_days"}}</option>
<option value="90" {{if eq $.Days "90"}}selected{{end}}>{{$d.T.Get "transactions.filter.option_90_days"}}</option>
<option value="365" {{if eq $.Days "365"}}selected{{end}}>{{$d.T.Get "transactions.filter.option_1_year"}}</option>
</select>
</div>
<div class="form-group" style="margin-bottom:0; flex:1; min-width:200px;">
<label>{{$d.T.Get "transactions.filter.label_search"}}</label>
<input type="text" name="search" placeholder="{{$d.T.Get "transactions.filter.placeholder_search"}}" value="{{.Search}}">
</div>
<button type="submit" class="btn btn-primary">{{$d.T.Get "transactions.filter.btn_filter"}}</button>
{{if or $.Cat $.Search $.Days}}
<a href="/transactions" class="btn btn-outline">{{$d.T.Get "transactions.filter.btn_clear"}}</a>
{{end}}
</form>
</div>
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<span class="text-muted">{{len $d.Txns}} transaction{{if ne (len $d.Txns) 1}}s{{end}}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>{{$d.T.Get "transactions.table.col_date"}}</th>
<th>{{$d.T.Get "transactions.table.col_description"}}</th>
<th>{{$d.T.Get "transactions.table.col_account"}}</th>
<th>{{$d.T.Get "transactions.table.col_category"}}</th>
<th class="text-right">{{$d.T.Get "transactions.table.col_amount"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $d.Txns}}
{{$color := index $d.CategoryColors .Category}}
<tr id="row-{{.ID}}">
<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 style="font-size:12.5px; color:var(--text3);">
{{$name := index $d.AccountNames .AccountID}}
{{if $name}}{{$name}}{{else}}<span style="opacity:.35;"></span>{{end}}
</td>
<td>
<!-- badge shown normally -->
<span id="cat-{{.ID}}" 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>
<!-- inline dropdown, hidden until pencil clicked -->
<select id="sel-{{.ID}}" style="display:none; font-size:12px; padding:4px 8px;
border:1px solid var(--border2); border-radius:6px; background:var(--bg2); color:var(--text);"
onchange="saveCategory('{{.ID}}', this.value)"
onblur="cancelEdit('{{.ID}}')">
{{range $d.Categories}}
<option value="{{.Name}}">{{.Name}}</option>
{{end}}
</select>
<button id="edit-btn-{{.ID}}" class="btn btn-outline btn-sm"
onclick="editCat('{{.ID}}')" title="{{$d.T.Get "transactions.table.btn_edit_category"}}"
style="margin-left:4px; padding:3px 7px;"></button>
</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>
<td>
<button class="btn btn-danger btn-sm" onclick="delTxn('{{.ID}}')" title="{{$d.T.Get "transactions.table.btn_delete"}}"></button>
</td>
</tr>
{{else}}
<tr>
<td colspan="6" class="text-center text-muted" style="padding:44px;">
{{$d.T.Get "transactions.table.empty_msg"}}
<a href="/import" style="color:var(--accent);">{{$d.T.Get "transactions.table.empty_import_link"}}</a> or
<button class="btn btn-outline btn-sm" onclick="openAddModal()" style="margin-left:4px;">{{$d.T.Get "transactions.table.empty_add_btn"}}</button>.
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- Add Transaction Modal -->
<div id="add-modal" style="display:none; position:fixed; inset:0;
background:rgba(0,0,0,0.55); backdrop-filter:blur(4px);
z-index:300; align-items:center; justify-content:center;">
<div class="card" style="width:440px; max-width:95vw; margin:0;
border:1px solid var(--border2); box-shadow:var(--shadow-lg);
animation:fadeUp 0.2s ease-out both;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<span style="font-size:16px; font-weight:700;">{{$d.T.Get "transactions.modal_add.title"}}</span>
<button class="btn btn-outline btn-sm" onclick="closeAddModal()" style="padding:3px 8px;"></button>
</div>
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.label_date"}}</label>
<input type="date" id="add-date" required>
</div>
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.label_description"}}</label>
<input type="text" id="add-desc" placeholder="{{$d.T.Get "transactions.modal_add.placeholder_desc"}}" required>
</div>
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.label_amount"}}</label>
<div style="display:flex; gap:8px;">
<select id="add-sign" style="width:110px; padding:9px 10px;
border:1px solid var(--border2); border-radius:var(--radius-sm);
background:var(--bg2); color:var(--text);">
<option value="-1">{{$d.T.Get "transactions.modal_add.option_expense"}}</option>
<option value="1">{{$d.T.Get "transactions.modal_add.option_income"}}</option>
</select>
<input type="number" id="add-amount" placeholder="{{$d.T.Get "transactions.modal_add.placeholder_amount"}}" step="0.01" min="0" style="flex:1;">
</div>
</div>
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.label_category"}}</label>
<select id="add-cat">
{{range $d.Categories}}
<option value="{{.Name}}">{{.Name}}</option>
{{end}}
{{if not $d.Categories}}<option value="Others">Others</option>{{end}}
</select>
</div>
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.label_account"}}</label>
<select id="add-account">
<option value="">{{$d.T.Get "transactions.modal_add.option_no_account"}}</option>
{{range $d.Accounts}}
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
{{end}}
</select>
</div>
{{if $d.Goals}}
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.label_goal"}}</label>
<select id="add-goal">
<option value="">{{$d.T.Get "transactions.modal_add.option_no_goal"}}</option>
{{range $d.Goals}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
{{end}}
<div id="add-error" class="error" style="display:none;"></div>
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:4px;">
<button class="btn btn-outline" onclick="closeAddModal()">{{$d.T.Get "transactions.modal_add.btn_cancel"}}</button>
<button class="btn btn-primary" onclick="submitAdd()">{{$d.T.Get "transactions.modal_add.btn_save"}}</button>
</div>
</div>
</div>
<script>
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
function editCat(id) {
document.getElementById('cat-' + id).style.display = 'none';
document.getElementById('edit-btn-' + id).style.display = 'none';
const sel = document.getElementById('sel-' + id);
sel.style.display = 'inline-block';
sel.focus();
}
function cancelEdit(id) {
setTimeout(() => {
document.getElementById('cat-' + id).style.display = 'inline-flex';
document.getElementById('edit-btn-' + id).style.display = 'inline-flex';
document.getElementById('sel-' + id).style.display = 'none';
}, 160);
}
function saveCategory(id, cat) {
fetch('/api/transactions/' + id, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({category: cat})
}).then(r => {
if (!r.ok) return;
const color = catColors[cat] || '';
const badge = document.getElementById('cat-' + id);
badge.style.background = color ? color + '18' : 'var(--bg3)';
badge.style.color = color || 'var(--text2)';
badge.style.borderColor = color ? color + '33' : 'var(--border2)';
badge.innerHTML = (color
? `<span class="category-dot" style="background:${color};box-shadow:0 0 4px ${color}88;"></span>`
: '') + cat;
cancelEdit(id);
});
}
function delTxn(id) {
if (!confirm('{{$d.T.Get "transactions.confirm.delete_msg"}}')) return;
fetch('/api/transactions/' + id, {method: 'DELETE'}).then(r => {
if (r.ok) document.getElementById('row-' + id).remove();
});
}
function openAddModal(preselectedGoalID) {
document.getElementById('add-modal').style.display = 'flex';
document.getElementById('add-date').value = new Date().toISOString().slice(0, 10);
if (preselectedGoalID) {
const goalSel = document.getElementById('add-goal');
if (goalSel) goalSel.value = preselectedGoalID;
const signSel = document.getElementById('add-sign');
if (signSel) signSel.value = '-1'; // goal contributions are expenses
}
document.getElementById('add-desc').focus();
}
function closeAddModal() {
document.getElementById('add-modal').style.display = 'none';
document.getElementById('add-error').style.display = 'none';
}
function submitAdd() {
const date = document.getElementById('add-date').value;
const desc = document.getElementById('add-desc').value.trim();
const sign = parseInt(document.getElementById('add-sign').value);
const amt = parseFloat(document.getElementById('add-amount').value);
const cat = document.getElementById('add-cat').value;
const acct = document.getElementById('add-account').value;
const goalEl = document.getElementById('add-goal');
const goalID = goalEl ? goalEl.value : '';
const errEl = document.getElementById('add-error');
if (!date || !desc || isNaN(amt) || amt <= 0) {
errEl.textContent = '{{$d.T.Get "transactions.modal_add.error_required"}}';
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
const body = {account_id: acct, date, description: desc, amount_cents: Math.round(amt * 100) * sign, category: cat};
if (goalID) body.goal_id = goalID;
fetch('/api/transactions', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
}).then(r => {
if (!r.ok) { errEl.textContent = '{{$d.T.Get "transactions.modal_add.error_save_failed"}}'; errEl.style.display = 'block'; return; }
closeAddModal();
location.reload();
});
}
// auto-open modal when redirected from goals page with ?fund_goal=<id>
(function() {
const params = new URLSearchParams(window.location.search);
const fundGoal = params.get('fund_goal');
if (fundGoal) openAddModal(fundGoal);
})();
document.getElementById('add-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); });
</script>
{{end}}