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:
parent
ac073acad9
commit
2ab3acdce2
@ -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)
|
||||
}
|
||||
|
||||
188
apps/finance/services/api/main/handler_dream.go
Normal file
188
apps/finance/services/api/main/handler_dream.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
99
apps/finance/services/api/main/models_dream.go
Normal file
99
apps/finance/services/api/main/models_dream.go
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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;">
|
||||
<!-- 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 & 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>
|
||||
|
||||
{{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;">
|
||||
|
||||
<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;">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 & 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>
|
||||
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);
|
||||
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' } }
|
||||
}
|
||||
// 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>
|
||||
|
||||
<!-- 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}}
|
||||
|
||||
359
apps/finance/services/api/main/templates/plan.html
Normal file
359
apps/finance/services/api/main/templates/plan.html
Normal 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 & 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}}
|
||||
Loading…
x
Reference in New Issue
Block a user