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>
This commit is contained in:
Gonçalo Rodrigues 2026-06-16 22:02:41 +01:00 committed by GitHub
parent ac073acad9
commit 2ab3acdce2
7 changed files with 1165 additions and 141 deletions

View File

@ -1793,27 +1793,35 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
action := r.FormValue("action")
if action == "delete" {
id := r.FormValue("id")
h.store.deleteGoal(ctx, id, a.UserID)
h.store.deleteGoal(ctx, r.FormValue("id"), a.UserID)
http.Redirect(w, r, "/goals", http.StatusSeeOther)
return
}
if action == "commit" || action == "uncommit" {
id := r.FormValue("id")
h.store.updateGoal(ctx, id, a.UserID, bson.M{"committed": action == "commit"})
h.store.updateGoal(ctx, r.FormValue("id"), a.UserID, bson.M{"committed": action == "commit"})
http.Redirect(w, r, "/goals", http.StatusSeeOther)
return
}
// create goal
if action == "adjust_deadline" {
months := parseIntParam(r.FormValue("months"), 0)
if months > 0 {
newDeadline := time.Now().AddDate(0, months, 0)
h.store.updateGoal(ctx, r.FormValue("id"), a.UserID, bson.M{"deadline": newDeadline})
}
http.Redirect(w, r, "/goals", http.StatusSeeOther)
return
}
// create goal (from planner tab or direct)
name := r.FormValue("name")
goalType := GoalType(r.FormValue("type"))
targetStr := r.FormValue("target_euros")
deadlineStr := r.FormValue("deadline")
targetEuros := parseFloat(targetStr)
deadline, _ := time.Parse("2006-01", deadlineStr)
if goalType == "" {
goalType = GoalTypeOnce
}
targetEuros := parseFloat(r.FormValue("target_euros"))
deadline, _ := time.Parse("2006-01", r.FormValue("deadline"))
g := &Goal{
ID: bson.NewObjectID().Hex(),
@ -1938,18 +1946,71 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
_ = conflictNames
}
render(w, goalsTmpl, &GoalsData{
data := &GoalsData{
UserID: a.UserID,
Email: a.Email,
Title: "Goals",
Route: "goals",
Tab: r.URL.Query().Get("tab"),
Goals: plans,
AvgMonthlySavings: avgMonthlySavings,
DisposableIncome: disposable,
CommittedMonthlyCents: committedTotal,
RemainingDisposable: remainingDisposable,
ConflictWarning: conflictWarning,
})
}
if data.Tab == "" {
data.Tab = "goals"
}
// Planner tab: load properties/loans and optionally run simulation
if data.Tab == "planner" {
q := r.URL.Query()
data.PlannerType = q.Get("planner_type")
props, _ := h.store.getProperties(ctx, a.UserID)
loans, _ := h.store.getLoans(ctx, a.UserID)
for _, p := range props {
data.PlanProperties = append(data.PlanProperties, toPropertyView(p, loans))
}
for _, l := range loans {
if l.Status == LoanActive {
data.PlanLoans = append(data.PlanLoans, toLoanView(l))
}
}
if q.Get("run") == "1" {
switch data.PlannerType {
case "purchase":
data.PurchaseResult = runPurchaseSim(
q.Get("name"),
parseFormCents(q.Get("target")),
parseFormCents(q.Get("monthly_savings")),
q.Get("deadline"),
)
data.HasPurchaseResult = true
case "transition":
termYears := parseIntParam(q.Get("const_term"), 30)
form := DreamForm{
PropertyID: q.Get("property_id"),
LoanID: q.Get("loan_id"),
DreamCostCents: parseFormCents(q.Get("dream_cost")),
DownPaymentPct: parseFloatParam(q.Get("down_pct"), 20),
ConstructionRatePct: parseFloatParam(q.Get("const_rate"), 4.0),
ConstructionTermYears: termYears,
ConstructionTermMonths: termYears * 12,
BuildMonths: parseIntParam(q.Get("build_months"), 18),
MonthlySavingsCents: parseFormCents(q.Get("monthly_savings")),
ExpectedSalePriceCents: parseFormCents(q.Get("sale_price")),
}
data.PlanForm = form
data.PlanResult = runDreamSim(form, data.PlanProperties, data.PlanLoans)
data.HasPlanResult = true
}
}
}
render(w, goalsTmpl, data)
}
func monthsBetween(from, to time.Time) int {
@ -2763,6 +2824,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /auto-import", h.AutoImport)
mux.HandleFunc("GET /property", h.Properties)
mux.HandleFunc("POST /property", h.Properties)
mux.HandleFunc("GET /plan", h.Dream)
h.RegisterOrgRoutes(mux)
}

View File

@ -0,0 +1,188 @@
package main
import (
"math"
"net/http"
"strconv"
"time"
)
// Dream redirects /plan to the Planner tab inside /goals.
func (h *Handler) Dream(w http.ResponseWriter, r *http.Request) {
target := "/goals?tab=planner"
if q := r.URL.RawQuery; q != "" {
target += "&" + q
}
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
// runDreamSim computes the four-phase goal plan.
func runDreamSim(form DreamForm, props []PropertyView, loans []LoanView) *DreamSimResult {
res := &DreamSimResult{Form: form}
for i := range props {
if props[i].ID == form.PropertyID {
res.CurrentProperty = &props[i]
break
}
}
for i := range loans {
if loans[i].ID == form.LoanID {
res.CurrentLoan = &loans[i]
break
}
}
if res.CurrentLoan != nil {
res.CurrentMonthlyCents = res.CurrentLoan.EffectiveMonthlyPaymentCents
}
// ── Phase 1: save the down payment ───────────────────────────────────────
res.DownPaymentCents = int64(math.Round(float64(form.DreamCostCents) * form.DownPaymentPct / 100))
if res.CurrentProperty != nil {
res.AlreadyHaveCents = res.CurrentProperty.EquityCents
}
res.StillNeededCents = res.DownPaymentCents - res.AlreadyHaveCents
if res.StillNeededCents < 0 {
res.StillNeededCents = 0
}
if res.StillNeededCents > 0 && form.MonthlySavingsCents > 0 {
res.Phase1Months = int(math.Ceil(float64(res.StillNeededCents) / float64(form.MonthlySavingsCents)))
}
res.Phase1EndDate = time.Now().AddDate(0, res.Phase1Months, 0)
// ── Construction loan details ─────────────────────────────────────────────
res.ConstructionLoanCents = form.DreamCostCents - res.DownPaymentCents
res.ConstructionMonthly = loanMonthlyPayment(res.ConstructionLoanCents, form.ConstructionRatePct, form.ConstructionTermMonths)
// ── Phase 2: acquisition period (both loans running) ─────────────────────
res.Phase2Months = form.BuildMonths
res.Phase2EndDate = res.Phase1EndDate.AddDate(0, res.Phase2Months, 0)
res.Phase2MonthlyCents = res.CurrentMonthlyCents + res.ConstructionMonthly
if res.CurrentLoan != nil {
totalElapsed := res.Phase1Months + res.Phase2Months
res.ExistingBalanceAtSale = loanBalanceAt(
res.CurrentLoan.PrincipalCents,
res.CurrentLoan.InterestRatePct,
res.CurrentLoan.EffectiveMonthlyPaymentCents,
totalElapsed,
)
}
res.ConstructionBalAtSale = loanBalanceAt(
res.ConstructionLoanCents,
form.ConstructionRatePct,
res.ConstructionMonthly,
res.Phase2Months,
)
// ── Phase 3: sell current asset ───────────────────────────────────────────
res.SalePriceCents = form.ExpectedSalePriceCents
if res.SalePriceCents == 0 && res.CurrentProperty != nil {
res.SalePriceCents = res.CurrentProperty.CurrentValueCents
}
res.MortgagePayoffCents = res.ExistingBalanceAtSale
res.NetProceedsCents = res.SalePriceCents - res.MortgagePayoffCents
if res.NetProceedsCents < 0 {
res.NetProceedsCents = 0
res.Warning = "Sale proceeds don't cover the remaining loan — you'll need to cover the gap."
}
res.RemainingBalanceCents = res.ConstructionBalAtSale - res.NetProceedsCents
if res.RemainingBalanceCents < 0 {
res.RemainingBalanceCents = 0
}
// ── Phase 4: goal achieved ────────────────────────────────────────────────
if res.RemainingBalanceCents > 0 {
res.Phase4Months = loanRemainingMonths(res.RemainingBalanceCents, form.ConstructionRatePct, res.ConstructionMonthly)
res.Phase4MonthlyCents = res.ConstructionMonthly
remainingTerm := form.ConstructionTermMonths - res.Phase2Months
if remainingTerm > 0 && remainingTerm < res.Phase4Months {
res.Phase4Months = remainingTerm
res.Phase4MonthlyCents = loanMonthlyPayment(res.RemainingBalanceCents, form.ConstructionRatePct, remainingTerm)
}
}
res.Phase4EndDate = res.Phase2EndDate.AddDate(0, res.Phase4Months, 0)
// ── Totals ────────────────────────────────────────────────────────────────
res.TotalMonths = res.Phase1Months + res.Phase2Months + res.Phase4Months
res.TotalYears = res.TotalMonths / 12
res.TotalRemMonths = res.TotalMonths % 12
res.FinalDate = res.Phase4EndDate
existingInterest := int64(0)
if res.CurrentLoan != nil {
totalPaid := res.CurrentMonthlyCents * int64(res.Phase1Months+res.Phase2Months)
principal := res.CurrentLoan.BalanceCents - res.ExistingBalanceAtSale
existingInterest = totalPaid - principal
if existingInterest < 0 {
existingInterest = 0
}
}
constructionTotalPaid := res.ConstructionMonthly*int64(res.Phase2Months) + res.Phase4MonthlyCents*int64(res.Phase4Months)
constructionInterest := constructionTotalPaid - res.ConstructionLoanCents
if constructionInterest < 0 {
constructionInterest = 0
}
res.TotalInterestCents = existingInterest + constructionInterest
return res
}
// runPurchaseSim computes a simple save-for-purchase projection.
func runPurchaseSim(name string, targetCents, monthlyCents int64, deadlineStr string) *PurchaseSimResult {
res := &PurchaseSimResult{
Name: name,
TargetCents: targetCents,
MonthlySavingsCents: monthlyCents,
}
if targetCents <= 0 {
return res
}
// At-current-savings: how long to reach target
if monthlyCents > 0 {
res.MonthsNeeded = int(math.Ceil(float64(targetCents) / float64(monthlyCents)))
res.YearsNeeded = res.MonthsNeeded / 12
res.RemMonths = res.MonthsNeeded % 12
res.ReachDate = time.Now().AddDate(0, res.MonthsNeeded, 0)
}
// Deadline projection
if deadlineStr != "" {
dl, err := time.Parse("2006-01", deadlineStr)
if err == nil && !dl.IsZero() {
res.HasDeadline = true
res.DeadlineDate = dl
res.DeadlineMonths = monthsBetween(time.Now(), dl)
if res.DeadlineMonths < 1 {
res.DeadlineMonths = 1
}
res.MonthlyNeededForDeadline = int64(math.Ceil(float64(targetCents) / float64(res.DeadlineMonths)))
res.Feasible = monthlyCents >= res.MonthlyNeededForDeadline
}
}
return res
}
func parseFloatParam(s string, def float64) float64 {
if s == "" {
return def
}
v, err := strconv.ParseFloat(s, 64)
if err != nil || v < 0 {
return def
}
return v
}
func parseIntParam(s string, def int) int {
if s == "" {
return def
}
v, err := strconv.Atoi(s)
if err != nil || v < 0 {
return def
}
return v
}

View File

@ -466,10 +466,22 @@ type GoalsData struct {
Email string
Title string
Route string
Tab string // "goals" or "planner"
Goals []GoalPlan
AvgMonthlySavings int64
DisposableIncome int64
CommittedMonthlyCents int64 // sum of all committed goal contributions
RemainingDisposable int64 // disposable after committed goals
ConflictWarning string // set when committing would exceed disposable
// Planner tab
PlannerType string // "purchase" or "transition"
PlanProperties []PropertyView
PlanLoans []LoanView
// Transition simulation
HasPlanResult bool
PlanResult *DreamSimResult
PlanForm DreamForm
// Purchase simulation
HasPurchaseResult bool
PurchaseResult *PurchaseSimResult
}

View File

@ -0,0 +1,99 @@
package main
import "time"
// DreamForm holds the raw user inputs echoed back to the template.
type DreamForm struct {
PropertyID string
LoanID string
DreamCostCents int64
DownPaymentPct float64
ConstructionRatePct float64
ConstructionTermYears int // what the user typed (years); converted to months internally
ConstructionTermMonths int // years * 12
BuildMonths int
MonthlySavingsCents int64
ExpectedSalePriceCents int64
}
// DreamSimResult is the computed output of one simulation run.
type DreamSimResult struct {
// Echoed inputs
Form DreamForm
// Current state (from selected property/loan)
CurrentProperty *PropertyView
CurrentLoan *LoanView
CurrentMonthlyCents int64
// Construction loan details
ConstructionLoanCents int64
ConstructionMonthly int64
// Phase 1 — save the down payment
DownPaymentCents int64
AlreadyHaveCents int64 // equity usable today
StillNeededCents int64
Phase1Months int
Phase1EndDate time.Time
// Phase 2 — construction (both loans running)
Phase2Months int
Phase2EndDate time.Time
Phase2MonthlyCents int64 // mortgage + construction EMI
ExistingBalanceAtSale int64 // mortgage balance at time of sale
ConstructionBalAtSale int64 // construction loan balance at time of sale
// Phase 3 — sell current house, pay down construction loan
SalePriceCents int64
MortgagePayoffCents int64
NetProceedsCents int64
RemainingBalanceCents int64 // construction loan after applying proceeds
// Phase 4 — just the construction loan
Phase4MonthlyCents int64
Phase4Months int
Phase4EndDate time.Time
// Totals
TotalMonths int
TotalYears int // TotalMonths / 12
TotalRemMonths int // TotalMonths % 12
FinalDate time.Time
TotalInterestCents int64
Warning string
}
// PurchaseSimResult is the computed output for a simple save-for-purchase goal.
type PurchaseSimResult struct {
Name string
TargetCents int64
MonthlySavingsCents int64
// At-current-savings projection
MonthsNeeded int
YearsNeeded int
RemMonths int
ReachDate time.Time
// Deadline projection (only set when a deadline was provided)
HasDeadline bool
DeadlineDate time.Time
DeadlineMonths int
MonthlyNeededForDeadline int64
Feasible bool // monthly savings >= monthly needed for deadline
}
// DreamData is passed to the dream.html template (kept for compat; redirect goes to /goals).
type DreamData struct {
UserID string
Email string
Title string
Route string
Properties []PropertyView
Loans []LoanView
HasResult bool
Result *DreamSimResult
Form DreamForm
}

View File

@ -580,6 +580,7 @@
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
<a href="/property" class="{{if eq .Route "property"}}active{{end}}">Property</a>
{{$analysisActive := or (eq .Route "reports") (eq .Route "projections") (eq .Route "networth") (eq .Route "simulator") (eq .Route "tax")}}
<div class="nav-group">
<button class="nav-group-btn {{if $analysisActive}}active{{end}}">
@ -624,6 +625,7 @@
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
<a href="/property" class="{{if eq .Route "property"}}active{{end}}">Property</a>
<a href="/people" class="{{if eq .Route "people"}}active{{end}}">People</a>
<hr>
<span class="nav-drawer-section-label">Analysis</span>

View File

@ -1,12 +1,27 @@
{{define "content"}}
{{$d := .}}
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:20px; 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>
<!-- Tab bar -->
<div style="display:flex; gap:4px; margin-bottom:24px; border-bottom:1px solid var(--border); padding-bottom:0;">
<a href="/goals?tab=goals"
style="padding:8px 16px; font-size:14px; font-weight:500; border-radius:6px 6px 0 0; text-decoration:none;
{{if eq $d.Tab "goals"}}background:var(--bg2); color:var(--text1); border:1px solid var(--border); border-bottom:1px solid var(--bg2); margin-bottom:-1px;{{else}}color:var(--text3);{{end}}">
Committed goals
</a>
<a href="/goals?tab=planner"
style="padding:8px 16px; font-size:14px; font-weight:500; border-radius:6px 6px 0 0; text-decoration:none;
{{if eq $d.Tab "planner"}}background:var(--bg2); color:var(--text1); border:1px solid var(--border); border-bottom:1px solid var(--bg2); margin-bottom:-1px;{{else}}color:var(--text3);{{end}}">
Goal Planner
</a>
</div>
{{if eq $d.Tab "goals"}}
{{/* ─── GOALS TAB ─────────────────────────────────────────────────────── */}}
{{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);">
@ -48,7 +63,6 @@
<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>
@ -73,20 +87,16 @@
</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}}
@ -98,14 +108,12 @@
<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>
@ -113,11 +121,23 @@
<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}};">
color:{{if .Feasible}}var(--green){{else}}var(--red){{end}}; display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
<span>
{{if .Feasible}}
✓ On track — your current savings rate covers the required €{{cents .MonthlyCents}}/month with {{.MonthsLeft}} months to go.
✓ On track — your current savings rate covers €{{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.
⚠ At your current rate (€{{cents $d.AvgMonthlySavings}}/mo) you'd reach this in {{.MonthsAtCurrentRate}} months — {{sub .MonthsAtCurrentRate .MonthsLeft}} months late.
{{end}}
</span>
{{if and (not .Feasible) (gt .MonthsAtCurrentRate 0)}}
<form method="POST" action="/goals" style="margin:0;">
<input type="hidden" name="action" value="adjust_deadline">
<input type="hidden" name="id" value="{{.ID}}">
<input type="hidden" name="months" value="{{.MonthsAtCurrentRate}}">
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red)44; white-space:nowrap;">
Adjust deadline →
</button>
</form>
{{end}}
</div>
@ -131,9 +151,7 @@
✓ Committed — click to uncommit
</button>
{{else}}
<button type="submit" class="btn btn-primary btn-sm">
Commit to this goal
</button>
<button type="submit" class="btn btn-primary btn-sm">Commit to this goal</button>
{{end}}
</form>
<form method="POST" action="/goals">
@ -151,127 +169,411 @@
<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>
<p style="margin-bottom:20px;">Use the <strong>Goal Planner</strong> tab to simulate a goal and save it here.</p>
<a href="/goals?tab=planner" class="btn btn-primary">Open Goal Planner →</a>
</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>
{{else}}
{{/* ─── PLANNER TAB ─────────────────────────────────────────────────────── */}}
{{$r := $d.PlanResult}}
{{$pr := $d.PurchaseResult}}
<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>
<!-- Type selector -->
<div class="card animate-on-scroll" style="margin-bottom:20px;">
<h2 style="margin-bottom:14px;">What kind of goal?</h2>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<a href="/goals?tab=planner&planner_type=purchase"
style="display:block; padding:16px 18px; border-radius:var(--radius); border:2px solid {{if eq $d.PlannerType "purchase"}}var(--accent){{else}}var(--border){{end}}; text-decoration:none; transition:border-color 0.15s;">
<div style="font-size:22px; margin-bottom:8px;">🛒</div>
<div style="font-weight:600; font-size:14px; color:var(--text1); margin-bottom:4px;">Save for a purchase</div>
<div style="font-size:12px; color:var(--text3);">Car, trip, gadget, fund — save up to a target by a date.</div>
</a>
<a href="/goals?tab=planner&planner_type=transition"
style="display:block; padding:16px 18px; border-radius:var(--radius); border:2px solid {{if eq $d.PlannerType "transition"}}var(--accent){{else}}var(--border){{end}}; text-decoration:none; transition:border-color 0.15s;">
<div style="font-size:22px; margin-bottom:8px;">🔄</div>
<div style="font-weight:600; font-size:14px; color:var(--text1); margin-bottom:4px;">Sell &amp; upgrade</div>
<div style="font-size:12px; color:var(--text3);">Own an asset with a loan, acquire something new, sell the old to fund it.</div>
</a>
</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] || '';
}
{{if eq $d.PlannerType "purchase"}}
<!-- ── PURCHASE FORM ──────────────────────────────────────────────────────── -->
<div class="card animate-on-scroll" style="margin-bottom:20px;">
<h2 style="margin-bottom:16px;">Your purchase goal</h2>
<form method="GET" action="/goals">
<input type="hidden" name="tab" value="planner">
<input type="hidden" name="planner_type" value="purchase">
<input type="hidden" name="run" value="1">
<div style="display:flex; flex-direction:column; gap:14px; margin-bottom:16px;">
// 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());
})();
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Goal name</label>
<input name="name" placeholder="e.g. New car, Europe trip, Emergency fund…"
value="{{if $d.HasPurchaseResult}}{{$pr.Name}}{{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>
// 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;
});
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Target amount (€)</label>
<input type="number" name="target" min="0" step="100"
value="{{if $d.HasPurchaseResult}}{{div $pr.TargetCents 100}}{{end}}"
placeholder="e.g. 12000"
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>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Monthly savings (€)</label>
<input type="number" name="monthly_savings" min="0" step="50"
value="{{if $d.HasPurchaseResult}}{{div $pr.MonthlySavingsCents 100}}{{end}}"
placeholder="e.g. 400"
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>
// close modal on backdrop click
document.getElementById('new-goal-modal').addEventListener('click', function(e) {
if (e.target === this) this.style.display = 'none';
});
</script>
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Target date (optional — leave blank to see when you'll get there)</label>
<input type="month" name="deadline"
value="{{if and $d.HasPurchaseResult $pr.HasDeadline}}{{$pr.DeadlineDate.Format "2006-01"}}{{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>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">Calculate →</button>
</form>
</div>
{{if $d.HasPurchaseResult}}
<!-- Purchase result -->
<div class="grid" style="margin-bottom:20px;">
<div class="card value-card animate-on-scroll">
<h2>At your savings rate</h2>
{{if gt $pr.MonthsNeeded 0}}
<div class="value positive">{{$pr.YearsNeeded}}y {{$pr.RemMonths}}m</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">reach {{dateShort $pr.ReachDate}}</p>
{{else}}
<div class="value" style="color:var(--text3); font-size:18px;"></div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">enter monthly savings</p>
{{end}}
</div>
<div class="card value-card animate-on-scroll">
<h2>Monthly needed</h2>
{{if $pr.HasDeadline}}
<div class="value {{if $pr.Feasible}}positive{{else}}negative{{end}}">€{{cents $pr.MonthlyNeededForDeadline}}</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">to hit {{dateShort $pr.DeadlineDate}}</p>
{{else}}
<div class="value" style="color:var(--text3); font-size:18px;"></div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">set a target date above</p>
{{end}}
</div>
<div class="card value-card animate-on-scroll">
<h2>Target</h2>
<div class="value positive">€{{cents $pr.TargetCents}}</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">your goal amount</p>
</div>
</div>
{{if $pr.HasDeadline}}
<div style="padding:12px 16px; border-radius:var(--radius); margin-bottom:20px; font-size:13px;
background:{{if $pr.Feasible}}rgba(74,222,128,0.08){{else}}rgba(248,113,113,0.08){{end}};
border:1px solid {{if $pr.Feasible}}rgba(74,222,128,0.25){{else}}rgba(248,113,113,0.25){{end}};
color:{{if $pr.Feasible}}var(--green){{else}}var(--red){{end}};">
{{if $pr.Feasible}}
✓ On track — €{{cents $pr.MonthlySavingsCents}}/mo covers the required €{{cents $pr.MonthlyNeededForDeadline}}/mo.
{{else}}
⚠ You need €{{cents $pr.MonthlyNeededForDeadline}}/mo to hit the deadline but you're saving €{{cents $pr.MonthlySavingsCents}}/mo. At your current rate you'll get there in {{$pr.MonthsNeeded}} months ({{dateShort $pr.ReachDate}}).
{{end}}
</div>
{{end}}
<!-- Save as goal -->
<div class="card animate-on-scroll">
<h2 style="margin-bottom:4px;">Save as a goal</h2>
<p style="font-size:13px; color:var(--text3); margin-bottom:14px;">Adds this to your Goals tab so you can commit to it.</p>
<form method="POST" action="/goals" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
<input type="hidden" name="type" value="once">
<input type="hidden" name="target_euros" value="{{div $pr.TargetCents 100}}">
<input type="hidden" name="deadline" value="{{if $pr.HasDeadline}}{{$pr.DeadlineDate.Format "2006-01"}}{{else}}{{$pr.ReachDate.Format "2006-01"}}{{end}}">
<div style="flex:1; min-width:200px;">
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Goal name</label>
<input name="name" required value="{{$pr.Name}}" placeholder="e.g. New car"
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>
<button type="submit" class="btn btn-primary" style="white-space:nowrap;">Save goal →</button>
</form>
</div>
{{end}}
{{else if eq $d.PlannerType "transition"}}
<!-- ── TRANSITION FORM ───────────────────────────────────────────────────── -->
<div class="card animate-on-scroll" style="margin-bottom:20px;">
<h2 style="margin-bottom:16px;">Your transition scenario</h2>
<form method="GET" action="/goals">
<input type="hidden" name="tab" value="planner">
<input type="hidden" name="planner_type" value="transition">
<input type="hidden" name="run" value="1">
<div class="grid" style="gap:14px; margin-bottom:16px;">
<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.PlanProperties}}
<option value="{{.ID}}" {{if and $d.HasPlanResult (eq $d.PlanForm.PropertyID .ID)}}selected{{end}}>{{.Name}} (€{{cents .CurrentValueCents}})</option>
{{end}}
</select>
</div>
<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.PlanLoans}}
<option value="{{.ID}}" {{if and $d.HasPlanResult (eq $d.PlanForm.LoanID .ID)}}selected{{end}}>{{.Name}} (€{{cents .BalanceCents}} left)</option>
{{end}}
</select>
</div>
<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.HasPlanResult}}{{div $d.PlanForm.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>
<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.HasPlanResult}}{{round $d.PlanForm.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>
<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.HasPlanResult}}{{$d.PlanForm.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>
<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.HasPlanResult}}{{$d.PlanForm.ConstructionTermYears}}{{else}}30{{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>
<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.HasPlanResult}}{{$d.PlanForm.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>
<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.HasPlanResult}}{{div $d.PlanForm.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>
<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.HasPlanResult}}{{div $d.PlanForm.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.HasPlanResult}}
{{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 cards -->
<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</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;">
<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:#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:#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:#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:#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:#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: <strong style="color:var(--green);">€{{cents $r.Phase4MonthlyCents}}/mo</strong></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>
{{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 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 = [], costs = [], 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('#f97316'); }
for (let i = 0; i < phase4Months; i++) { labels.push('Goal '+(i+1)); costs.push(phase4Monthly); colors.push(green); }
const step = Math.ceil(labels.length / 24);
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: i => labels[i[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>
<!-- Save as goal (transition) -->
<div class="card animate-on-scroll">
<h2 style="margin-bottom:4px;">Save as a goal</h2>
<p style="font-size:13px; color:var(--text3); margin-bottom:14px;">Adds this to your Goals tab so you can commit to it.</p>
<form method="POST" action="/goals" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
<input type="hidden" name="type" value="deposit">
<input type="hidden" name="target_euros" value="{{div $r.Form.DreamCostCents 100}}">
<input type="hidden" name="deadline" value="{{$r.FinalDate.Format "2006-01"}}">
<div style="flex:1; min-width:200px;">
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">Goal name</label>
<input name="name" required placeholder="e.g. New property, Upgrade car…"
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>
<button type="submit" class="btn btn-primary" style="white-space:nowrap;">Save goal →</button>
</form>
</div>
{{end}}{{/* end HasPlanResult */}}
{{end}}{{/* end planner_type transition */}}
{{end}}{{/* end tab */}}
{{end}}

View File

@ -0,0 +1,359 @@
{{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}}