Adds a full translation layer (English + European Portuguese) using
BurntSushi/toml with go:embed. Locale detection reads the lang cookie,
falls back to Accept-Language, then defaults to "en". A language switcher
in the nav writes the cookie and redirects back. All 20 personal finance
templates now use {{.T.Get "key"}} for every UI string.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
289 lines
14 KiB
HTML
289 lines
14 KiB
HTML
{{define "content"}}
|
||
{{$d := .}}
|
||
|
||
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
|
||
<h1>{{$d.T.Get "simulator.title"}}</h1>
|
||
<span class="text-muted">{{$d.T.Get "simulator.subtitle"}}</span>
|
||
</div>
|
||
|
||
<!-- Live output cards -->
|
||
<div class="grid" style="margin-bottom:16px;">
|
||
<div class="card value-card animate-on-scroll">
|
||
<h2>{{$d.T.Get "simulator.cards.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;">{{$d.T.Get "simulator.cards.disposable_sub"}}</p>
|
||
</div>
|
||
<div class="card value-card animate-on-scroll">
|
||
<h2>{{$d.T.Get "simulator.cards.monthly_savings"}}</h2>
|
||
<div id="out-savings" class="value positive">€0.00</div>
|
||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "simulator.cards.monthly_savings_sub"}}</p>
|
||
</div>
|
||
<div class="card value-card animate-on-scroll">
|
||
<h2>{{$d.T.Get "simulator.cards.savings_rate"}}</h2>
|
||
<div id="out-rate" class="value positive">0%</div>
|
||
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "simulator.cards.savings_rate_sub"}}</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>{{$d.T.Get "simulator.controls.section_title"}}</h2>
|
||
<button onclick="resetAll()" class="btn btn-outline btn-sm">{{$d.T.Get "simulator.controls.btn_reset"}}</button>
|
||
</div>
|
||
|
||
<div style="display:flex; flex-direction:column; gap:24px;">
|
||
|
||
<div>
|
||
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||
<label style="font-size:13px; color:var(--text2); font-weight:500;">{{$d.T.Get "simulator.controls.label_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>
|
||
|
||
<div>
|
||
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||
<label style="font-size:13px; color:var(--text2); font-weight:500;">{{$d.T.Get "simulator.controls.label_one_off"}}</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>
|
||
|
||
<div>
|
||
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
|
||
<label style="font-size:13px; color:var(--text2); font-weight:500;">{{$d.T.Get "simulator.controls.label_fixed_costs"}}</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>
|
||
|
||
<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;">
|
||
{{$d.T.Get "simulator.controls.label_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;">{{$d.T.Get "simulator.controls.label_goal_amount"}}</label>
|
||
<input type="number" id="ng-amount" min="0" step="100" placeholder="{{$d.T.Get "simulator.controls.placeholder_goal_amount"}}"
|
||
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;">{{$d.T.Get "simulator.controls.label_goal_months"}}</label>
|
||
<input type="number" id="ng-months" min="1" max="120" step="1" placeholder="{{$d.T.Get "simulator.controls.placeholder_goal_months"}}"
|
||
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);">
|
||
{{$d.T.Get "simulator.controls.goal_would_need"}} <span id="ng-monthly" style="font-weight:600; color:var(--accent);">{{$d.T.Get "simulator.controls.per_month"}}</span>
|
||
{{$d.T.Get "simulator.controls.leaving"}} <span id="ng-after" style="font-weight:600;">{{$d.T.Get "simulator.controls.disposable_after"}}</span>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Goal timeline impact -->
|
||
<div class="card animate-on-scroll">
|
||
<h2 style="margin-bottom:16px;">{{$d.T.Get "simulator.goal_impact.section_title"}}</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;">{{$.T.Get "simulator.goal_impact.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>{{$d.T.Get "simulator.goal_impact.no_goals_msg"}} <a href="/goals" style="color:var(--accent);">{{$d.T.Get "simulator.goal_impact.goals_link"}}</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>{{$d.T.Get "simulator.history_chart.section_title"}}</h2>
|
||
<span style="font-size:11px; color:var(--text3);">{{$d.T.Get "simulator.history_chart.subtitle"}}</span>
|
||
</div>
|
||
<canvas id="savings-chart" height="200"></canvas>
|
||
</div>
|
||
{{end}}
|
||
|
||
<script>
|
||
const BASE = {
|
||
income: {{$d.IncomeCents}},
|
||
fixed: {{$d.FixedCents}},
|
||
goals: {{$d.GoalsCents}},
|
||
disposable: {{$d.DisposableCents}},
|
||
avgSavings: {{$d.AvgSavingsCents}},
|
||
};
|
||
|
||
const MSG = {
|
||
notAchievable: {{$d.T.Get "simulator.goal_impact.feasibility_not_achievable" | printf "%q"}},
|
||
onTrack: {{$d.T.Get "simulator.goal_impact.feasibility_on_track" | printf "%q"}},
|
||
faster: {{$d.T.Get "simulator.goal_impact.feasibility_faster" | printf "%q"}},
|
||
over: {{$d.T.Get "simulator.goal_impact.feasibility_over" | printf "%q"}},
|
||
};
|
||
|
||
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;
|
||
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;
|
||
|
||
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';
|
||
|
||
setCard('out-disposable', newDisp);
|
||
setCard('out-savings', newSavings);
|
||
document.getElementById('out-rate').textContent = newRate + '%';
|
||
document.getElementById('out-rate').className = 'value ' + (newRate >= 0 ? 'positive' : 'negative');
|
||
|
||
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)';
|
||
|
||
document.querySelectorAll('.goal-row').forEach(row => {
|
||
const monthlyCents = parseInt(row.dataset.monthly);
|
||
const originalMonths = parseInt(row.dataset.months);
|
||
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 = MSG.notAchievable;
|
||
feasEl.style.color = 'var(--red)';
|
||
} else if (feasible) {
|
||
feasEl.textContent = diff < 0 ? Math.abs(diff) + ' ' + MSG.faster : MSG.onTrack;
|
||
feasEl.style.color = 'var(--green)';
|
||
} else {
|
||
feasEl.textContent = diff + ' ' + MSG.over;
|
||
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();
|
||
}
|
||
|
||
recalc();
|
||
|
||
{{if $d.SavingsHistory}}
|
||
(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}}
|