Gonçalo Rodrigues 2ab3acdce2 feat(goals): Goal Planner — type-driven planner merged into /goals tab
* feat(property): Layer 3 — Dream House Simulator

Add /dream page with a four-phase simulation engine:

  Phase 1 — Save the down payment (uses current property equity)
  Phase 2 — Construction period (both loans running simultaneously)
  Phase 3 — Sell current house, apply proceeds to construction loan
  Phase 4 — Final state: just the construction loan remaining

Inputs: dream cost, down payment %, construction loan rate/term,
build duration, monthly savings, expected sale price. All pre-filled
from existing property/loan data when available.

Output: per-phase timeline cards, monthly cost bar chart, total
interest, final payoff date, and a key levers section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(plan): rename Dream House to Goal Planner at /plan

- Route /dream → /plan
- Nav label "Dream House" → "Goal Planner"
- Template dream.html → plan.html
- All user-facing labels generalised (construction loan → new loan,
  build duration → acquisition/build period, current property →
  current asset, dream house cost → new goal cost, etc.)
- Empty state updated with generic copy and 🎯 icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(goals): merge Goal Planner into /goals as a second tab

- /goals now has two tabs: "Committed goals" and "Goal Planner"
- Goal creation only happens from the Planner tab (simulate first,
  then "Save as goal" → creates an uncommitted goal)
- Commitment, deadline adjustment, and deletion stay on the Goals tab
- Off-track goals show an "Adjust deadline →" button that pushes the
  deadline to the realistic date based on current savings rate
- /plan and /dream both redirect to /goals?tab=planner (301)
- "Goal Planner" nav link removed; plan.html kept for redirect compat
- GoalsData gains Tab, PlanProperties, PlanLoans, HasPlanResult,
  PlanResult, PlanForm fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(goals): type-driven planner — Save for a purchase vs Sell & upgrade

Goal Planner tab now opens with two goal type cards:

  🛒 Save for a purchase — name, target, monthly savings, optional
     deadline. Shows time-to-reach at current rate, monthly needed
     to hit the deadline, and a feasibility banner.

  🔄 Sell & upgrade — the full four-phase transition simulator
     (existing asset + loan → acquire new → sell old → payoff).

Each type has its own focused form and result section. Selecting a
type highlights the card and loads the matching form. Results include
a "Save as goal" action that drops an uncommitted goal into the
Goals tab.

Also adds runPurchaseSim() and PurchaseSimResult model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 22:02:41 +01:00

360 lines
19 KiB
HTML

{{define "content"}}
{{$d := .}}
{{$r := .Result}}
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
<h1>Goal Planner</h1>
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
</div>
<!-- Inputs form -->
<div class="card animate-on-scroll" style="margin-bottom:20px;">
<h2 style="margin-bottom:4px;">Your scenario</h2>
<p style="font-size:13px; color:var(--text3); margin-bottom:16px;">
Model any transition where you hold an asset with a loan, want to acquire a new one, then sell the old to fund the new.
</p>
<form method="GET" action="/plan">
<input type="hidden" name="run" value="1">
<div class="grid" style="gap:14px; margin-bottom:16px;">
<!-- Current asset -->
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Current asset (optional)</label>
<select name="property_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
<option value="">— none selected —</option>
{{range $d.Properties}}
<option value="{{.ID}}" {{if and $d.HasResult (eq $d.Form.PropertyID .ID)}}selected{{end}}>{{.Name}} (€{{cents .CurrentValueCents}})</option>
{{end}}
</select>
</div>
<!-- Current loan -->
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Current loan (optional)</label>
<select name="loan_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
<option value="">— none selected —</option>
{{range $d.Loans}}
<option value="{{.ID}}" {{if and $d.HasResult (eq $d.Form.LoanID .ID)}}selected{{end}}>{{.Name}} (€{{cents .BalanceCents}} left)</option>
{{end}}
</select>
</div>
<!-- Goal cost -->
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">New goal cost (€)</label>
<input type="number" name="dream_cost" min="0" step="1000"
value="{{if $d.HasResult}}{{div $d.Form.DreamCostCents 100}}{{end}}"
placeholder="e.g. 350000"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<!-- Down payment % -->
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Down payment (%)</label>
<input type="number" name="down_pct" min="0" max="100" step="1"
value="{{if $d.HasResult}}{{round $d.Form.DownPaymentPct}}{{else}}20{{end}}"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<!-- New loan rate -->
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">New loan rate (% annual)</label>
<input type="number" name="const_rate" min="0" max="30" step="0.1"
value="{{if $d.HasResult}}{{$d.Form.ConstructionRatePct}}{{else}}4.0{{end}}"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<!-- New loan term -->
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">New loan term (years)</label>
<input type="number" name="const_term" min="1" max="40" step="1"
value="{{if $d.HasResult}}{{$d.Form.ConstructionTermYears}}{{else}}30{{end}}"
placeholder="30"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<!-- Acquisition period -->
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Acquisition / build period (months)</label>
<input type="number" name="build_months" min="1" max="60" step="1"
value="{{if $d.HasResult}}{{$d.Form.BuildMonths}}{{else}}18{{end}}"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<!-- Monthly savings -->
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Monthly savings available (€)</label>
<input type="number" name="monthly_savings" min="0" step="100"
value="{{if $d.HasResult}}{{div $d.Form.MonthlySavingsCents 100}}{{end}}"
placeholder="e.g. 800"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<!-- Expected sale price -->
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Expected sale price of current asset (€)</label>
<input type="number" name="sale_price" min="0" step="1000"
value="{{if $d.HasResult}}{{div $d.Form.ExpectedSalePriceCents 100}}{{end}}"
placeholder="leave blank to use current value"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">Run simulation →</button>
</form>
</div>
{{if $d.HasResult}}
{{if $r.Warning}}
<div style="background:rgba(251,191,36,0.12); border:1px solid rgba(251,191,36,0.4); border-radius:var(--radius); padding:12px 16px; margin-bottom:16px; font-size:13px; color:var(--text2);">
⚠️ {{$r.Warning}}
</div>
{{end}}
<!-- Summary bar -->
<div class="grid" style="margin-bottom:20px;">
<div class="card value-card animate-on-scroll">
<h2>Total timeline</h2>
<div class="value positive">{{$r.TotalYears}}y {{$r.TotalRemMonths}}m</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">until goal is fully paid off</p>
</div>
<div class="card value-card animate-on-scroll">
<h2>Final monthly cost</h2>
<div class="value positive">€{{cents $r.Phase4MonthlyCents}}</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">after selling current asset</p>
</div>
<div class="card value-card animate-on-scroll">
<h2>Total interest</h2>
<div class="value" style="color:var(--red);">€{{cents $r.TotalInterestCents}}</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">across both loans combined</p>
</div>
<div class="card value-card animate-on-scroll">
<h2>Free by</h2>
<div class="value positive" style="font-size:24px;">{{dateShort $r.FinalDate}}</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">fully paid off</p>
</div>
</div>
<!-- Phase timeline -->
<div class="card animate-on-scroll" style="margin-bottom:20px;">
<h2 style="margin-bottom:20px;">Your roadmap</h2>
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:0; position:relative;">
<!-- connector bar -->
<div style="position:absolute; top:20px; left:12.5%; right:12.5%; height:3px; background:var(--border); z-index:0; border-radius:2px;"></div>
<!-- Phase 1 -->
<div style="text-align:center; position:relative; padding:0 8px;">
<div style="width:40px; height:40px; border-radius:50%; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">1</div>
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Save down payment</div>
{{if gt $r.Phase1Months 0}}
<div style="font-size:22px; font-weight:500; color:var(--accent); margin-bottom:4px;">{{$r.Phase1Months}}mo</div>
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase1EndDate}}</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div>Target: <strong>€{{cents $r.DownPaymentCents}}</strong></div>
<div>Already have: <strong>€{{cents $r.AlreadyHaveCents}}</strong></div>
<div>Still need: <strong>€{{cents $r.StillNeededCents}}</strong></div>
<div>Saving: <strong>€{{cents $r.Form.MonthlySavingsCents}}/mo</strong></div>
</div>
{{else}}
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">Ready now!</div>
<div style="font-size:11px; color:var(--text3);">equity covers down payment</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div>Down payment: <strong>€{{cents $r.DownPaymentCents}}</strong></div>
<div>Your equity: <strong style="color:var(--green);">€{{cents $r.AlreadyHaveCents}}</strong></div>
</div>
{{end}}
</div>
<!-- Phase 2 -->
<div style="text-align:center; position:relative; padding:0 8px;">
<div style="width:40px; height:40px; border-radius:50%; background:var(--orange, #f97316); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">2</div>
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Acquire / build</div>
<div style="font-size:22px; font-weight:500; color:var(--orange, #f97316); margin-bottom:4px;">{{$r.Phase2Months}}mo</div>
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase2EndDate}}</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div>New loan: <strong>€{{cents $r.ConstructionLoanCents}}</strong></div>
{{if $r.CurrentLoan}}
<div>Existing loan: <strong>€{{cents $r.CurrentMonthlyCents}}/mo</strong></div>
{{end}}
<div>New EMI: <strong>€{{cents $r.ConstructionMonthly}}/mo</strong></div>
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">Total burden: <strong style="color:var(--orange, #f97316);">€{{cents $r.Phase2MonthlyCents}}/mo</strong></div>
</div>
</div>
<!-- Phase 3 -->
<div style="text-align:center; position:relative; padding:0 8px;">
<div style="width:40px; height:40px; border-radius:50%; background:var(--teal, #14b8a6); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">3</div>
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Sell &amp; transition</div>
<div style="font-size:16px; font-weight:600; color:var(--teal, #14b8a6); margin-bottom:4px;">One-time event</div>
<div style="font-size:11px; color:var(--text3);">after acquisition completes</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div>Sale price: <strong>€{{cents $r.SalePriceCents}}</strong></div>
<div>Pay off loan: <strong style="color:var(--red);">-€{{cents $r.MortgagePayoffCents}}</strong></div>
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">Net proceeds: <strong style="color:var(--green);">€{{cents $r.NetProceedsCents}}</strong></div>
<div>Applied to new loan</div>
</div>
</div>
<!-- Phase 4 -->
<div style="text-align:center; position:relative; padding:0 8px;">
<div style="width:40px; height:40px; border-radius:50%; background:var(--green); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">4</div>
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">Goal achieved</div>
{{if gt $r.Phase4Months 0}}
<div style="font-size:22px; font-weight:500; color:var(--green); margin-bottom:4px;">{{$r.Phase4Months}}mo</div>
<div style="font-size:11px; color:var(--text3);">paid off {{dateShort $r.Phase4EndDate}}</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div>Remaining loan: <strong>€{{cents $r.RemainingBalanceCents}}</strong></div>
<div>Monthly payment: <strong style="color:var(--green);">€{{cents $r.Phase4MonthlyCents}}/mo</strong></div>
<div style="font-size:11px; color:var(--text3); margin-top:4px;">just the new loan</div>
</div>
{{else}}
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">Fully paid!</div>
<div style="font-size:11px; color:var(--text3);">sale proceeds cleared the loan</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div style="color:var(--green); font-weight:600;">No remaining loan!</div>
<div>The sale covers everything.</div>
</div>
{{end}}
</div>
</div>
</div>
<!-- Monthly cost chart -->
<div class="card animate-on-scroll" style="margin-bottom:20px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2>Monthly cost over time</h2>
<span style="font-size:11px; color:var(--text3);">what you pay each month</span>
</div>
<canvas id="cost-chart" height="160"></canvas>
</div>
<script>
(function() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const style = getComputedStyle(document.documentElement);
const accent = style.getPropertyValue('--accent').trim() || '#6979f8';
const green = style.getPropertyValue('--green').trim() || '#4ade80';
const orange = '#f97316';
const phase1Months = {{$r.Phase1Months}};
const phase2Months = {{$r.Phase2Months}};
const phase4Months = {{$r.Phase4Months}};
const existingMonthly = {{$r.CurrentMonthlyCents}} / 100;
const phase2Monthly = {{$r.Phase2MonthlyCents}} / 100;
const phase4Monthly = {{$r.Phase4MonthlyCents}} / 100;
const labels = [];
const costs = [];
const colors = [];
for (let i = 0; i < phase1Months; i++) {
labels.push('Save ' + (i+1));
costs.push(existingMonthly);
colors.push(accent);
}
for (let i = 0; i < phase2Months; i++) {
labels.push('Acquire ' + (i+1));
costs.push(phase2Monthly);
colors.push(orange);
}
for (let i = 0; i < phase4Months; i++) {
labels.push('Goal ' + (i+1));
costs.push(phase4Monthly);
colors.push(green);
}
const maxLabels = 24;
const step = Math.ceil(labels.length / maxLabels);
const displayLabels = labels.map((l, i) => i % step === 0 ? l : '');
new Chart(document.getElementById('cost-chart').getContext('2d'), {
type: 'bar',
data: {
labels: displayLabels,
datasets: [{
data: costs,
backgroundColor: colors,
borderRadius: 3,
borderSkipped: false,
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: (items) => labels[items[0].dataIndex],
label: (c) => '€' + c.parsed.y.toLocaleString('pt-PT', {minimumFractionDigits:2}) + '/mo'
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: isDark ? '#5c6585' : '#9fa8c7', font: { size: 10 } }
},
y: {
grid: { color: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.05)' },
ticks: {
color: isDark ? '#5c6585' : '#9fa8c7',
callback: v => '€' + (v/1000).toFixed(0) + 'k'
}
}
}
}
});
})();
</script>
<!-- Key levers -->
<div class="card animate-on-scroll">
<h2 style="margin-bottom:14px;">Key levers</h2>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; font-size:13px; color:var(--text2);">
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">Save more monthly</div>
<div>Each extra €100/mo shortens Phase 1 and gets you acquiring sooner.</div>
</div>
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">Increase the down payment</div>
<div>A higher down % reduces the new loan and lowers the double-burden in Phase 2.</div>
</div>
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">Sell at a higher price</div>
<div>Every extra euro from the sale goes straight to reducing the new loan balance.</div>
</div>
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">Negotiate the rate</div>
<div>Even 0.5% less on the new loan saves thousands over the full term.</div>
</div>
</div>
</div>
{{else}}
<!-- Empty state -->
<div class="card empty-state animate-on-scroll">
<div style="font-size:48px; margin-bottom:16px;">🎯</div>
<h3>Plan your next big goal</h3>
<p style="margin-bottom:8px; max-width:480px; margin-left:auto; margin-right:auto;">
Model any transition where you hold an asset with a loan, want to acquire something new,
and plan to sell the old to fund it — including the double-payment period,
the sale, and the final payoff date.
</p>
{{if not $d.Properties}}
<p style="font-size:13px; color:var(--text3); margin-top:12px;">
Tip: <a href="/property" style="color:var(--accent);">add your current asset and loan</a> first so the planner can pre-fill the numbers.
</p>
{{end}}
</div>
{{end}}
{{end}}