Gonçalo Rodrigues 4b7c01e632 feat(finance): i18n — TOML-based translations for all personal finance templates
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>
2026-06-17 22:32:49 +01:00

289 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{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}}