Merge pull request #11 from GoncaloRodri/feature/phase5-simulator
feat: phases 4 & 5 — net worth + what-if simulator
This commit is contained in:
commit
2b1ee6422a
@ -117,6 +117,7 @@ var (
|
|||||||
sharingTmpl = parseTmpl("templates/base.html", "templates/sharing.html")
|
sharingTmpl = parseTmpl("templates/base.html", "templates/sharing.html")
|
||||||
goalsTmpl = parseTmpl("templates/base.html", "templates/goals.html")
|
goalsTmpl = parseTmpl("templates/base.html", "templates/goals.html")
|
||||||
networthTmpl = parseTmpl("templates/base.html", "templates/networth.html")
|
networthTmpl = parseTmpl("templates/base.html", "templates/networth.html")
|
||||||
|
simulatorTmpl = parseTmpl("templates/base.html", "templates/simulator.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
type authInfo struct {
|
type authInfo struct {
|
||||||
@ -1545,6 +1546,136 @@ func parseFloat(s string) float64 {
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Simulator(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
a := getAuth(r)
|
||||||
|
|
||||||
|
txns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
||||||
|
goals, _ := h.store.getGoals(ctx, a.UserID)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||||
|
|
||||||
|
// this month income + fixed costs
|
||||||
|
thisMonthIncome := int64(0)
|
||||||
|
totalFixed := int64(0)
|
||||||
|
fixedByMonth := make(map[string]map[int]int64)
|
||||||
|
threeAgo := thisStart.AddDate(0, -3, 0)
|
||||||
|
monthlySavings := make(map[string]struct{ income, saved int64 })
|
||||||
|
|
||||||
|
for _, t := range txns {
|
||||||
|
mk := t.Date.Format("2006-01")
|
||||||
|
if t.Date.Before(thisStart) {
|
||||||
|
// savings history: accumulate per month
|
||||||
|
ms := monthlySavings[mk]
|
||||||
|
if t.AmountCents > 0 {
|
||||||
|
ms.income += t.AmountCents
|
||||||
|
}
|
||||||
|
ms.saved += t.AmountCents
|
||||||
|
monthlySavings[mk] = ms
|
||||||
|
|
||||||
|
// fixed category detection over last 3 months
|
||||||
|
if !t.Date.Before(threeAgo) && FixedCategories[t.Category] && t.AmountCents < 0 {
|
||||||
|
mo := int(t.Date.Month())
|
||||||
|
if fixedByMonth[t.Category] == nil {
|
||||||
|
fixedByMonth[t.Category] = make(map[int]int64)
|
||||||
|
}
|
||||||
|
fixedByMonth[t.Category][mo] += -t.AmountCents
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if t.AmountCents > 0 {
|
||||||
|
thisMonthIncome += t.AmountCents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// avg monthly fixed from last 3 months
|
||||||
|
for _, byMo := range fixedByMonth {
|
||||||
|
total := int64(0)
|
||||||
|
for _, v := range byMo {
|
||||||
|
total += v
|
||||||
|
}
|
||||||
|
totalFixed += total / int64(len(byMo))
|
||||||
|
}
|
||||||
|
|
||||||
|
// committed goal monthly totals
|
||||||
|
goalsCents := int64(0)
|
||||||
|
var simGoals []SimulatorGoal
|
||||||
|
for _, g := range goals {
|
||||||
|
remaining := g.TargetCents - g.SavedCents
|
||||||
|
if remaining <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ml := int64(monthsBetween(now, g.Deadline))
|
||||||
|
if ml < 1 {
|
||||||
|
ml = 1
|
||||||
|
}
|
||||||
|
monthly := remaining / ml
|
||||||
|
if g.Committed {
|
||||||
|
goalsCents += monthly
|
||||||
|
}
|
||||||
|
simGoals = append(simGoals, SimulatorGoal{
|
||||||
|
Name: g.Name,
|
||||||
|
MonthlyCents: monthly,
|
||||||
|
MonthsLeft: ml,
|
||||||
|
Committed: g.Committed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
disposable := thisMonthIncome - totalFixed - goalsCents
|
||||||
|
|
||||||
|
// average monthly savings over last 3 complete months
|
||||||
|
var avgSavings int64
|
||||||
|
count := 0
|
||||||
|
for _, ms := range monthlySavings {
|
||||||
|
if ms.income > 0 && ms.saved > 0 {
|
||||||
|
avgSavings += ms.saved
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
avgSavings /= int64(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// savings rate history — sorted months
|
||||||
|
var monthKeys []string
|
||||||
|
for mk := range monthlySavings {
|
||||||
|
monthKeys = append(monthKeys, mk)
|
||||||
|
}
|
||||||
|
sortStrings(monthKeys)
|
||||||
|
var history []SavingsPoint
|
||||||
|
for _, mk := range monthKeys {
|
||||||
|
ms := monthlySavings[mk]
|
||||||
|
if ms.income <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rate := int(float64(ms.saved) / float64(ms.income) * 100)
|
||||||
|
if rate < -100 {
|
||||||
|
rate = -100
|
||||||
|
}
|
||||||
|
history = append(history, SavingsPoint{
|
||||||
|
Month: mk,
|
||||||
|
IncomeCents: ms.income,
|
||||||
|
SavedCents: ms.saved,
|
||||||
|
RatePct: rate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render(w, simulatorTmpl, &SimulatorData{
|
||||||
|
UserID: a.UserID,
|
||||||
|
Email: a.Email,
|
||||||
|
Title: "What If",
|
||||||
|
Route: "simulator",
|
||||||
|
IncomeCents: thisMonthIncome,
|
||||||
|
FixedCents: totalFixed,
|
||||||
|
GoalsCents: goalsCents,
|
||||||
|
DisposableCents: disposable,
|
||||||
|
AvgSavingsCents: avgSavings,
|
||||||
|
Goals: simGoals,
|
||||||
|
SavingsHistory: history,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
a := getAuth(r)
|
a := getAuth(r)
|
||||||
@ -1653,6 +1784,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /goals", h.Goals)
|
mux.HandleFunc("GET /goals", h.Goals)
|
||||||
mux.HandleFunc("POST /goals", h.Goals)
|
mux.HandleFunc("POST /goals", h.Goals)
|
||||||
mux.HandleFunc("GET /networth", h.NetWorth)
|
mux.HandleFunc("GET /networth", h.NetWorth)
|
||||||
|
mux.HandleFunc("GET /simulator", h.Simulator)
|
||||||
mux.HandleFunc("GET /sharing", h.Sharing)
|
mux.HandleFunc("GET /sharing", h.Sharing)
|
||||||
mux.HandleFunc("POST /sharing", h.Sharing)
|
mux.HandleFunc("POST /sharing", h.Sharing)
|
||||||
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)
|
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)
|
||||||
|
|||||||
@ -219,6 +219,38 @@ type SharingUser struct {
|
|||||||
Email string
|
Email string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SimulatorGoal struct {
|
||||||
|
Name string
|
||||||
|
MonthlyCents int64
|
||||||
|
MonthsLeft int64
|
||||||
|
Committed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type SimulatorData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
|
||||||
|
// current state passed to JS
|
||||||
|
IncomeCents int64
|
||||||
|
FixedCents int64 // recurring fixed costs (no goals)
|
||||||
|
GoalsCents int64 // committed goal contributions
|
||||||
|
DisposableCents int64 // income − fixed − goals
|
||||||
|
AvgSavingsCents int64 // 3-month avg monthly savings
|
||||||
|
Goals []SimulatorGoal
|
||||||
|
|
||||||
|
// savings rate history: one point per past month
|
||||||
|
SavingsHistory []SavingsPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
type SavingsPoint struct {
|
||||||
|
Month string
|
||||||
|
IncomeCents int64
|
||||||
|
SavedCents int64
|
||||||
|
RatePct int
|
||||||
|
}
|
||||||
|
|
||||||
type NetWorthPoint struct {
|
type NetWorthPoint struct {
|
||||||
Month string // "2025-01"
|
Month string // "2025-01"
|
||||||
AssetCents int64
|
AssetCents int64
|
||||||
|
|||||||
@ -401,6 +401,7 @@
|
|||||||
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
|
<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="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
|
||||||
<a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">Net Worth</a>
|
<a href="/networth" class="{{if eq .Route "networth"}}active{{end}}">Net Worth</a>
|
||||||
|
<a href="/simulator" class="{{if eq .Route "simulator"}}active{{end}}">What If</a>
|
||||||
<a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a>
|
<a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a>
|
||||||
<div class="nav-spacer"></div>
|
<div class="nav-spacer"></div>
|
||||||
<span class="nav-email">{{.Email}}</span>
|
<span class="nav-email">{{.Email}}</span>
|
||||||
|
|||||||
295
apps/finance/services/api/main/templates/simulator.html
Normal file
295
apps/finance/services/api/main/templates/simulator.html
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
||||||
|
<h1>What If…</h1>
|
||||||
|
<span class="text-muted">adjust sliders to see the ripple effect</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live output cards -->
|
||||||
|
<div class="grid" style="margin-bottom:16px;">
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Disposable income</h2>
|
||||||
|
<div id="out-disposable" class="value" style="color:var(--text);">€0.00</div>
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">after fixed costs & goals</p>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Monthly savings</h2>
|
||||||
|
<div id="out-savings" class="value positive">€0.00</div>
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">income − all committed spend</p>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Savings rate</h2>
|
||||||
|
<div id="out-rate" class="value positive">0%</div>
|
||||||
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">savings ÷ income</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls + goal impact side by side -->
|
||||||
|
<div class="grid-2" style="margin-bottom:16px;">
|
||||||
|
|
||||||
|
<!-- Sliders -->
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h2>Adjustments</h2>
|
||||||
|
<button onclick="resetAll()" class="btn btn-outline btn-sm">Reset</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:24px;">
|
||||||
|
|
||||||
|
<!-- Income change -->
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||||||
|
<label style="font-size:13px; color:var(--text2); font-weight:500;">Income change</label>
|
||||||
|
<span id="lbl-income" style="font-size:13px; font-weight:600; color:var(--accent);">+0%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="sl-income" min="-50" max="100" value="0" step="1"
|
||||||
|
oninput="recalc()" style="width:100%; accent-color:var(--accent);">
|
||||||
|
<div style="display:flex; justify-content:space-between; font-size:11px; color:var(--text3); margin-top:4px;">
|
||||||
|
<span>−50%</span><span>+100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- One-off expense -->
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||||||
|
<label style="font-size:13px; color:var(--text2); font-weight:500;">One-off expense</label>
|
||||||
|
<span id="lbl-oneoff" style="font-size:13px; font-weight:600; color:var(--red);">€0</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="sl-oneoff" min="0" max="10000" value="0" step="50"
|
||||||
|
oninput="recalc()" style="width:100%; accent-color:var(--red);">
|
||||||
|
<div style="display:flex; justify-content:space-between; font-size:11px; color:var(--text3); margin-top:4px;">
|
||||||
|
<span>€0</span><span>€10 000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fixed costs change -->
|
||||||
|
<div>
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||||||
|
<label style="font-size:13px; color:var(--text2); font-weight:500;">Fixed costs change</label>
|
||||||
|
<span id="lbl-fixed" style="font-size:13px; font-weight:600; color:var(--text2);">+€0/mo</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="sl-fixed" min="-500" max="1000" value="0" step="10"
|
||||||
|
oninput="recalc()" style="width:100%; accent-color:var(--accent);">
|
||||||
|
<div style="display:flex; justify-content:space-between; font-size:11px; color:var(--text3); margin-top:4px;">
|
||||||
|
<span>−€500</span><span>+€1 000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New goal -->
|
||||||
|
<div style="border-top:1px solid var(--border); padding-top:20px;">
|
||||||
|
<label style="font-size:13px; color:var(--text2); font-weight:500; display:block; margin-bottom:12px;">
|
||||||
|
Hypothetical new goal
|
||||||
|
</label>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:10px;">
|
||||||
|
<div>
|
||||||
|
<label style="font-size:11px; color:var(--text3); display:block; margin-bottom:4px;">Amount (€)</label>
|
||||||
|
<input type="number" id="ng-amount" min="0" step="100" placeholder="5 000"
|
||||||
|
oninput="recalc()"
|
||||||
|
style="width:100%; padding:8px 10px; background:var(--bg3); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:13px;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="font-size:11px; color:var(--text3); display:block; margin-bottom:4px;">Months to save</label>
|
||||||
|
<input type="number" id="ng-months" min="1" max="120" step="1" placeholder="12"
|
||||||
|
oninput="recalc()"
|
||||||
|
style="width:100%; padding:8px 10px; background:var(--bg3); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:13px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px; color:var(--text3);">
|
||||||
|
Would need <span id="ng-monthly" style="font-weight:600; color:var(--accent);">€0/mo</span>
|
||||||
|
— leaving <span id="ng-after" style="font-weight:600;">€0/mo</span> disposable
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Goal timeline impact -->
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<h2 style="margin-bottom:16px;">Goal timeline impact</h2>
|
||||||
|
{{if $d.Goals}}
|
||||||
|
<div id="goal-impact-list" style="display:flex; flex-direction:column; gap:10px;">
|
||||||
|
{{range $d.Goals}}
|
||||||
|
<div class="goal-row" data-monthly="{{.MonthlyCents}}" data-months="{{.MonthsLeft}}" data-committed="{{.Committed}}">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:4px;">
|
||||||
|
<span style="font-size:13px; font-weight:500; color:var(--text);">
|
||||||
|
{{.Name}}
|
||||||
|
{{if .Committed}}<span style="font-size:10px; color:var(--green); margin-left:5px;">committed</span>{{end}}
|
||||||
|
</span>
|
||||||
|
<span class="goal-months" style="font-size:13px; font-weight:600; color:var(--text2);">{{.MonthsLeft}}mo</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--bg3); border-radius:99px; height:4px; overflow:hidden;">
|
||||||
|
<div class="goal-bar" style="height:100%; border-radius:99px; width:0%; background:var(--accent); transition:width 0.4s ease;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="goal-feasibility" style="font-size:11px; color:var(--text3); margin-top:3px;"></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state" style="padding:24px;">
|
||||||
|
<p>No goals yet. <a href="/goals" style="color:var(--accent);">Add some →</a></p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Savings rate history chart -->
|
||||||
|
{{if $d.SavingsHistory}}
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
|
<h2>Savings rate history</h2>
|
||||||
|
<span style="font-size:11px; color:var(--text3);">past months</span>
|
||||||
|
</div>
|
||||||
|
<canvas id="savings-chart" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── State seeded from server ──────────────────────────────────────────────
|
||||||
|
const BASE = {
|
||||||
|
income: {{$d.IncomeCents}},
|
||||||
|
fixed: {{$d.FixedCents}},
|
||||||
|
goals: {{$d.GoalsCents}},
|
||||||
|
disposable: {{$d.DisposableCents}},
|
||||||
|
avgSavings: {{$d.AvgSavingsCents}},
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmt(cents) {
|
||||||
|
const abs = Math.abs(cents);
|
||||||
|
const sign = cents < 0 ? '−' : '';
|
||||||
|
return sign + '€' + (abs / 100).toLocaleString('pt-PT', {minimumFractionDigits:2, maximumFractionDigits:2});
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalc() {
|
||||||
|
const incomePct = parseFloat(document.getElementById('sl-income').value) / 100;
|
||||||
|
const oneoff = parseFloat(document.getElementById('sl-oneoff').value) * 100; // convert € to cents
|
||||||
|
const fixedDelta = parseFloat(document.getElementById('sl-fixed').value) * 100;
|
||||||
|
|
||||||
|
const newIncome = Math.round(BASE.income * (1 + incomePct));
|
||||||
|
const newFixed = BASE.fixed + fixedDelta;
|
||||||
|
const newDisp = newIncome - newFixed - BASE.goals - oneoff;
|
||||||
|
const newSavings = newIncome - newFixed - BASE.goals;
|
||||||
|
const newRate = newIncome > 0 ? Math.round(newSavings / newIncome * 100) : 0;
|
||||||
|
|
||||||
|
// labels
|
||||||
|
document.getElementById('lbl-income').textContent = (incomePct >= 0 ? '+' : '') + Math.round(incomePct * 100) + '%';
|
||||||
|
document.getElementById('lbl-income').style.color = incomePct >= 0 ? 'var(--green)' : 'var(--red)';
|
||||||
|
document.getElementById('lbl-oneoff').textContent = '€' + (oneoff/100).toLocaleString('pt-PT');
|
||||||
|
document.getElementById('lbl-fixed').textContent = (fixedDelta >= 0 ? '+' : '−') + '€' + (Math.abs(fixedDelta)/100).toLocaleString('pt-PT') + '/mo';
|
||||||
|
|
||||||
|
// output cards
|
||||||
|
setCard('out-disposable', newDisp);
|
||||||
|
setCard('out-savings', newSavings);
|
||||||
|
document.getElementById('out-rate').textContent = newRate + '%';
|
||||||
|
document.getElementById('out-rate').className = 'value ' + (newRate >= 0 ? 'positive' : 'negative');
|
||||||
|
|
||||||
|
// new goal impact
|
||||||
|
const ngAmt = parseFloat(document.getElementById('ng-amount').value || '0') * 100;
|
||||||
|
const ngMonths = parseFloat(document.getElementById('ng-months').value || '0');
|
||||||
|
const ngMonthly = ngMonths > 0 ? Math.round(ngAmt / ngMonths) : 0;
|
||||||
|
const ngAfter = newDisp - ngMonthly;
|
||||||
|
document.getElementById('ng-monthly').textContent = '€' + (ngMonthly/100).toLocaleString('pt-PT', {minimumFractionDigits:2});
|
||||||
|
const ngAfterEl = document.getElementById('ng-after');
|
||||||
|
ngAfterEl.textContent = fmt(ngAfter) + '/mo';
|
||||||
|
ngAfterEl.style.color = ngAfter >= 0 ? 'var(--green)' : 'var(--red)';
|
||||||
|
|
||||||
|
// goal rows
|
||||||
|
document.querySelectorAll('.goal-row').forEach(row => {
|
||||||
|
const monthlyCents = parseInt(row.dataset.monthly);
|
||||||
|
const originalMonths = parseInt(row.dataset.months);
|
||||||
|
const committed = row.dataset.committed === 'true';
|
||||||
|
|
||||||
|
// at the new savings rate, how many months to hit the same target?
|
||||||
|
const target = monthlyCents * originalMonths;
|
||||||
|
const effectiveSavings = newSavings > 0 ? newSavings : 0;
|
||||||
|
const newMonths = effectiveSavings > 0 ? Math.ceil(target / effectiveSavings) : 9999;
|
||||||
|
const feasible = newMonths <= originalMonths;
|
||||||
|
|
||||||
|
row.querySelector('.goal-months').textContent = newMonths > 999 ? '—' : newMonths + 'mo';
|
||||||
|
row.querySelector('.goal-months').style.color = feasible ? 'var(--green)' : 'var(--red)';
|
||||||
|
|
||||||
|
const pct = Math.min(100, Math.round(originalMonths / Math.max(newMonths, 1) * 100));
|
||||||
|
row.querySelector('.goal-bar').style.width = pct + '%';
|
||||||
|
row.querySelector('.goal-bar').style.background = feasible ? 'var(--green)' : 'var(--red)';
|
||||||
|
|
||||||
|
const diff = newMonths - originalMonths;
|
||||||
|
const feasEl = row.querySelector('.goal-feasibility');
|
||||||
|
if (newMonths > 999) {
|
||||||
|
feasEl.textContent = 'Not achievable at this savings level';
|
||||||
|
feasEl.style.color = 'var(--red)';
|
||||||
|
} else if (feasible) {
|
||||||
|
feasEl.textContent = diff < 0 ? Math.abs(diff) + ' months faster ✓' : 'On track ✓';
|
||||||
|
feasEl.style.color = 'var(--green)';
|
||||||
|
} else {
|
||||||
|
feasEl.textContent = diff + ' months over deadline';
|
||||||
|
feasEl.style.color = 'var(--red)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCard(id, cents) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.textContent = fmt(cents);
|
||||||
|
el.className = 'value ' + (cents >= 0 ? 'positive' : 'negative');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAll() {
|
||||||
|
document.getElementById('sl-income').value = 0;
|
||||||
|
document.getElementById('sl-oneoff').value = 0;
|
||||||
|
document.getElementById('sl-fixed').value = 0;
|
||||||
|
document.getElementById('ng-amount').value = '';
|
||||||
|
document.getElementById('ng-months').value = '';
|
||||||
|
recalc();
|
||||||
|
}
|
||||||
|
|
||||||
|
// run on load
|
||||||
|
recalc();
|
||||||
|
|
||||||
|
{{if $d.SavingsHistory}}
|
||||||
|
// ── Savings rate chart ────────────────────────────────────────────────────
|
||||||
|
(function() {
|
||||||
|
const labels = [{{range $d.SavingsHistory}}"{{.Month}}",{{end}}];
|
||||||
|
const rates = [{{range $d.SavingsHistory}}{{.RatePct}},{{end}}];
|
||||||
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
const ctx = document.getElementById('savings-chart').getContext('2d');
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Savings rate %',
|
||||||
|
data: rates,
|
||||||
|
backgroundColor: rates.map(r => r >= 0
|
||||||
|
? (isDark ? 'rgba(74,222,128,0.5)' : 'rgba(22,163,74,0.5)')
|
||||||
|
: (isDark ? 'rgba(248,113,113,0.5)' : 'rgba(220,38,38,0.5)')),
|
||||||
|
borderColor: rates.map(r => r >= 0
|
||||||
|
? (isDark ? '#4ade80' : '#16a34a')
|
||||||
|
: (isDark ? '#f87171' : '#dc2626')),
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderRadius: 4,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { callbacks: { label: ctx => ctx.parsed.y + '%' } }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.05)' },
|
||||||
|
ticks: { color: isDark ? '#5c6585' : '#9fa8c7', maxTicksLimit: 12 }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.05)' },
|
||||||
|
ticks: { color: isDark ? '#5c6585' : '#9fa8c7', callback: v => v + '%' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
{{end}}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
Loading…
x
Reference in New Issue
Block a user