* 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>
189 lines
6.5 KiB
Go
189 lines
6.5 KiB
Go
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
|
|
}
|