Gonçalo Rodrigues 3b041267ad feat: phase 3 — goals commit + plan
- Commit/uncommit button on each goal card
- Committed goals are deducted from disposable income on the dashboard
  (Available to spend now reflects reserved goal contributions)
- Conflict detection: warning banner when committed goals exceed
  disposable income, showing the shortfall
- Goals summary bar: disposable before goals, reserved per month,
  free to spend after committed goals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 16:37:45 +01:00

278 lines
13 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>Goals</h1>
<button onclick="document.getElementById('new-goal-modal').style.display='flex'"
class="btn btn-primary btn-sm">+ New goal</button>
</div>
{{if $d.ConflictWarning}}
<div style="padding:14px 18px; border-radius:10px; margin-bottom:16px; font-size:13px;
background:rgba(248,113,113,0.08); border:1px solid rgba(248,113,113,0.25); color:var(--red);">
⚠ {{$d.ConflictWarning}}
</div>
{{end}}
{{if $d.AvgMonthlySavings}}
<div style="display:flex; gap:10px; margin-bottom:20px; flex-wrap:wrap;">
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
<h2>Avg monthly savings</h2>
<div class="value positive animate-counter" data-target="{{$d.AvgMonthlySavings}}" data-prefix="€">€0</div>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">last 3 months</p>
</div>
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
<h2>Disposable income</h2>
<div class="value animate-counter" data-target="{{$d.DisposableIncome}}" data-prefix="€" style="color:var(--text);">€0</div>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">before goals</p>
</div>
{{if $d.CommittedMonthlyCents}}
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
<h2>Reserved for goals</h2>
<div class="value negative animate-counter" data-target="{{$d.CommittedMonthlyCents}}" data-prefix="€">€0</div>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">per month</p>
</div>
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
<h2>Free to spend</h2>
<div class="value animate-counter {{if lt $d.RemainingDisposable 0}}negative{{else}}positive{{end}}"
data-target="{{$d.RemainingDisposable}}" data-prefix="€">€0</div>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">after goals</p>
</div>
{{end}}
</div>
{{end}}
{{if $d.Goals}}
<div style="display:flex; flex-direction:column; gap:14px;">
{{range $d.Goals}}
<div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<!-- left: name + type + progress -->
<div style="flex:1; min-width:200px;">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:4px;">
<span style="font-size:18px;">{{if eq .Type "once"}}🎯{{else if eq .Type "deposit"}}🏠{{else if eq .Type "emergency"}}🛡️{{else}}📈{{end}}</span>
<div>
<div style="font-size:15px; font-weight:600; color:var(--text);">{{.Name}}</div>
<div style="font-size:11px; color:var(--text3); text-transform:uppercase; letter-spacing:.4px;">
{{if eq .Type "once"}}One-off purchase{{else if eq .Type "deposit"}}Deposit / down-payment{{else if eq .Type "emergency"}}Emergency fund{{else}}Recurring investment{{end}}
</div>
</div>
</div>
<div style="margin-top:12px;">
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
<span style="font-size:12px; color:var(--text2);">€{{cents .SavedCents}} saved of €{{cents .TargetCents}}</span>
<span style="font-size:12px; color:var(--text2);">{{.ProgressPct}}%</span>
</div>
<div style="background:var(--bg3); border-radius:99px; height:6px; 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>
</div>
<!-- right: projection numbers -->
<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;">
<div style="text-align:center;">
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">Need per month</div>
<div class="animate-counter" style="font-size:18px; font-weight:600; color:var(--text);"
data-target="{{.MonthlyCents}}" data-prefix="€">€0</div>
</div>
<div style="text-align:center;">
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">Months left</div>
<div style="font-size:18px; font-weight:600; color:var(--text);">{{.MonthsLeft}}</div>
</div>
<div style="text-align:center;">
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">At current rate</div>
{{if gt .MonthsAtCurrentRate 0}}
<div style="font-size:18px; font-weight:600;
{{if .Feasible}}color:var(--green){{else}}color:var(--red){{end}};">
{{.MonthsAtCurrentRate}}mo
</div>
{{else}}
<div style="font-size:14px; color:var(--text3);"></div>
{{end}}
</div>
<div style="text-align:center;">
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">Disposable after</div>
<div class="animate-counter" style="font-size:18px; font-weight:600;
{{if ge .ImpactOnDisposable 0}}color:var(--green){{else}}color:var(--red){{end}};"
data-target="{{.ImpactOnDisposable}}" data-prefix="€">€0</div>
</div>
</div>
</div>
<!-- feasibility banner -->
<div style="margin-top:14px; padding:10px 14px; border-radius:8px; font-size:13px;
background:{{if .Feasible}}rgba(74,222,128,0.08){{else}}rgba(248,113,113,0.08){{end}};
border:1px solid {{if .Feasible}}rgba(74,222,128,0.2){{else}}rgba(248,113,113,0.2){{end}};
color:{{if .Feasible}}var(--green){{else}}var(--red){{end}};">
{{if .Feasible}}
✓ On track — your current savings rate covers the required €{{cents .MonthlyCents}}/month with {{.MonthsLeft}} months to go.
{{else}}
⚠ At your current savings rate (€{{cents $d.AvgMonthlySavings}}/mo) you'd reach this in {{.MonthsAtCurrentRate}} months, missing the deadline by {{sub .MonthsAtCurrentRate .MonthsLeft}} months.
{{end}}
</div>
<!-- commit / uncommit / delete -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:14px; flex-wrap:wrap; gap:8px;">
<form method="POST" action="/goals">
<input type="hidden" name="action" value="{{if .Committed}}uncommit{{else}}commit{{end}}">
<input type="hidden" name="id" value="{{.ID}}">
{{if .Committed}}
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--green); border-color:var(--green)55;">
✓ Committed — click to uncommit
</button>
{{else}}
<button type="submit" class="btn btn-primary btn-sm">
Commit to this goal
</button>
{{end}}
</form>
<form method="POST" action="/goals">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red)33;"
onclick="return confirm('Remove this goal?')">Remove</button>
</form>
</div>
</div>
{{end}}
</div>
{{else}}
<div class="card empty-state animate-on-scroll">
<div style="font-size:48px; margin-bottom:16px;">🎯</div>
<h3>No goals yet</h3>
<p style="margin-bottom:20px;">Plan a purchase, a house deposit, or an emergency fund.<br>See exactly how much you need to save each month to get there.</p>
<button onclick="document.getElementById('new-goal-modal').style.display='flex'" class="btn btn-primary">
Add your first goal
</button>
</div>
{{end}}
<!-- New goal modal -->
<div id="new-goal-modal" style="display:none; position:fixed; inset:0; z-index:1000;
background:rgba(0,0,0,0.6); backdrop-filter:blur(4px);
align-items:center; justify-content:center; padding:20px;">
<div style="background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius);
padding:28px; width:100%; max-width:460px; position:relative;">
<h2 style="margin-bottom:20px;">New goal</h2>
<form method="POST" action="/goals" style="display:flex; flex-direction:column; gap:16px;">
<div>
<label style="font-size:12px; color:var(--text2); display:block; margin-bottom:6px;">Goal name</label>
<input name="name" required placeholder="e.g. Nintendo Switch, House deposit…"
style="width:100%; padding:9px 12px; background:var(--bg3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:14px;">
</div>
<div>
<label style="font-size:12px; color:var(--text2); display:block; margin-bottom:6px;">Type</label>
<select name="type" id="goal-type" onchange="updateTypeHint(this.value)"
style="width:100%; padding:9px 12px; background:var(--bg3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:14px;">
<option value="once">🎯 One-off purchase</option>
<option value="deposit">🏠 Deposit / down-payment</option>
<option value="emergency">🛡️ Emergency fund</option>
<option value="investment">📈 Recurring investment</option>
</select>
<p id="type-hint" style="font-size:12px; color:var(--text3); margin-top:5px;">
A specific purchase you want to save up for.
</p>
</div>
<div>
<label style="font-size:12px; color:var(--text2); display:block; margin-bottom:6px;">Target amount (€)</label>
<input name="target_euros" type="number" min="1" step="0.01" required placeholder="500"
style="width:100%; padding:9px 12px; background:var(--bg3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:14px;">
</div>
<div>
<label style="font-size:12px; color:var(--text2); display:block; margin-bottom:6px;">Target date</label>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<select id="deadline-month"
style="padding:9px 12px; background:var(--bg3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:14px;">
<option value="01">January</option>
<option value="02">February</option>
<option value="03">March</option>
<option value="04">April</option>
<option value="05">May</option>
<option value="06">June</option>
<option value="07">July</option>
<option value="08">August</option>
<option value="09">September</option>
<option value="10">October</option>
<option value="11">November</option>
<option value="12">December</option>
</select>
<select id="deadline-year"
style="padding:9px 12px; background:var(--bg3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:14px;">
</select>
</div>
<!-- hidden field submitted to server -->
<input type="hidden" name="deadline" id="deadline-value">
</div>
<div style="display:flex; gap:10px; justify-content:flex-end; margin-top:4px;">
<button type="button" onclick="document.getElementById('new-goal-modal').style.display='none'"
class="btn btn-outline">Cancel</button>
<button type="submit" class="btn btn-primary">Save goal</button>
</div>
</form>
</div>
</div>
<script>
const typeHints = {
once: 'A specific purchase you want to save up for.',
deposit: 'A lump sum needed for a house deposit or down-payment.',
emergency: 'A safety net covering several months of expenses.',
investment: 'A recurring investment target (e.g. ETF monthly contribution).',
};
function updateTypeHint(v) {
document.getElementById('type-hint').textContent = typeHints[v] || '';
}
// populate year dropdown: current year + 10 years ahead
(function() {
const now = new Date();
const yearSel = document.getElementById('deadline-year');
const monthSel = document.getElementById('deadline-month');
for (let y = now.getFullYear(); y <= now.getFullYear() + 10; y++) {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
yearSel.appendChild(opt);
}
// default month to next month
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
monthSel.value = String(nextMonth.getMonth() + 1).padStart(2, '0');
yearSel.value = String(nextMonth.getFullYear());
})();
// wire hidden field before submit
document.querySelector('#new-goal-modal form').addEventListener('submit', function() {
const m = document.getElementById('deadline-month').value;
const y = document.getElementById('deadline-year').value;
document.getElementById('deadline-value').value = y + '-' + m;
});
// close modal on backdrop click
document.getElementById('new-goal-modal').addEventListener('click', function(e) {
if (e.target === this) this.style.display = 'none';
});
</script>
{{end}}