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

243 lines
9.9 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 := .}}
<h1 style="margin-bottom:24px;">{{$d.T.Get "portfolio.title"}}</h1>
{{if $d.Holdings}}
{{if $d.MissingPrices}}
<div style="background:rgba(245,158,11,0.08); border:1px solid rgba(245,158,11,0.35); border-radius:12px; padding:16px 20px; margin-bottom:20px;">
<div style="font-weight:600; font-size:0.9rem; margin-bottom:12px;">⚠ {{$d.T.Get "portfolio.missing_prices_warn"}}</div>
{{range $d.MissingPrices}}
<form method="post" action="/portfolio/ticker" style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
<input type="hidden" name="isin" value="{{.}}">
<code style="font-size:0.8rem; color:var(--muted); min-width:140px;">{{.}}</code>
<input type="text" name="ticker" placeholder="{{$d.T.Get "portfolio.ticker_placeholder"}}" required
style="padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text); font-size:0.85rem; width:130px;">
<button type="submit" style="padding:6px 14px; background:var(--accent); color:#fff; border:none; border-radius:8px; font-size:0.85rem; font-weight:600; cursor:pointer;">{{$d.T.Get "portfolio.btn_save_ticker"}}</button>
<a href="https://finance.yahoo.com/lookup/" target="_blank" rel="noopener"
style="font-size:0.78rem; color:var(--accent);">{{$d.T.Get "portfolio.lookup_link"}}</a>
</form>
{{end}}
</div>
{{end}}
<div class="grid">
<div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "portfolio.cards.total_value"}}</h2>
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}} animate-counter"
data-target="{{$d.TotalValueCents}}" data-prefix="€">€0.00</div>
</div>
<div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "portfolio.cards.total_cost"}}</h2>
<div class="value animate-counter" data-target="{{$d.TotalCostCents}}" data-prefix="€"
style="color:var(--text);">€0.00</div>
</div>
<div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "portfolio.cards.unrealized_pl"}}</h2>
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}} animate-counter"
data-target="{{$d.TotalPCLCents}}" data-prefix="€">€0.00</div>
<p style="font-size:13px; margin-top:6px; color:{{if eq (pctSign $d.TotalPCLPct) "+"}}var(--green){{else}}var(--red){{end}};">
{{pctSign $d.TotalPCLPct}}{{printf "%.2f" $d.TotalPCLPct}}%
</p>
</div>
</div>
<div class="grid-2" style="align-items:start;">
<!-- Allocation donut -->
<div class="card animate-on-scroll">
<h2 style="margin-bottom:16px;">{{$d.T.Get "portfolio.allocation.section_title"}}</h2>
<div id="allocation3d" style="width:100%; height:380px; position:relative;"></div>
</div>
<!-- Holdings table -->
<div class="card animate-on-scroll">
<h2 style="margin-bottom:14px;">{{$d.T.Get "portfolio.holdings.section_title"}}</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>{{$d.T.Get "portfolio.holdings.col_asset"}}</th>
<th class="text-right">{{$d.T.Get "portfolio.holdings.col_shares"}}</th>
<th class="text-right">{{$d.T.Get "portfolio.holdings.col_avg_cost"}}</th>
<th class="text-right">{{$d.T.Get "portfolio.holdings.col_price"}}</th>
<th class="text-right">{{$d.T.Get "portfolio.holdings.col_value"}}</th>
<th class="text-right">{{$d.T.Get "portfolio.holdings.col_pl"}}</th>
</tr>
</thead>
<tbody>
{{range $i, $h := $d.Holdings}}
<tr>
<td>
<div style="font-weight:600; font-size:13.5px;">{{.Name}}</div>
<div class="text-muted" style="font-size:11.5px; margin-top:1px;">{{.ISIN}}</div>
</td>
<td class="cents" style="font-size:13px; color:var(--text2);">{{printf "%.4f" .SharesOwned}}</td>
<td class="cents" style="font-size:13px;">€{{cents .AvgEntryCents}}</td>
<td class="cents" style="font-size:13px;">€{{cents .CurrentPriceCents}}</td>
<td class="cents" style="font-weight:600;">€{{cents .CurrentValueCents}}</td>
<td class="cents">
<div class="{{if ge .UnrealizedPCLCents 0}}positive{{else}}negative{{end}}" style="font-weight:600; font-size:13px;">
{{pctSign .UnrealizedPCLPct}}{{printf "%.2f" .UnrealizedPCLPct}}%
</div>
<div class="{{if ge .UnrealizedPCLCents 0}}positive{{else}}negative{{end}}" style="font-size:11.5px; opacity:0.8;">
{{if ge .UnrealizedPCLCents 0}}+{{else}}{{end}}€{{cents (centsAbs .UnrealizedPCLCents)}}
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div style="margin-top:16px; padding-top:14px; border-top:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;">
<span class="text-muted">{{$d.T.Get "portfolio.holdings.add_trades_via"}}</span>
<a href="/import" class="btn btn-outline btn-sm">{{$d.T.Get "portfolio.holdings.btn_import"}}</a>
</div>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const palette = [
'#00d4ff','#0099cc','#00b894','#007a63','#005f8a',
'#00e5cc','#0077b6','#48cae4','#023e8a','#00b4d8',
];
const holdings = [
{{range $d.Holdings}}{ name: "{{.Name}}", value: {{.CurrentValueCents}} },{{end}}
].filter(h => h.value > 0);
const total = holdings.reduce((s, h) => s + h.value, 0);
if (total > 0) {
const container = document.getElementById('allocation3d');
const W = container.clientWidth, H = 380;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, W / H, 0.1, 100);
camera.position.set(0, 5, 9);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(W, H);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.autoRotate = true;
controls.autoRotateSpeed = 1.2;
controls.minDistance = 5;
controls.maxDistance = 16;
const tip = Object.assign(document.createElement('div'), {
style: 'position:absolute;background:rgba(15,17,23,0.92);color:#e8eaf6;padding:7px 13px;border-radius:8px;font-size:13px;pointer-events:none;opacity:0;transition:opacity .15s;z-index:10;border:1px solid rgba(255,255,255,0.08);backdrop-filter:blur(8px);'
});
container.appendChild(tip);
const group = new THREE.Group();
const IR = 1.3, OR = 3.0, D = 0.55;
let angle = 0, hovered = null;
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const meshes = [];
holdings.forEach((h, i) => {
const arc = (h.value / total) * Math.PI * 2;
const color = palette[i % palette.length];
const mid = angle + arc / 2;
const shape = new THREE.Shape();
shape.moveTo(IR, 0);
shape.absarc(0, 0, OR, angle, angle + arc, false);
shape.absarc(0, 0, IR, angle + arc, angle, true);
shape.closePath();
const geo = new THREE.ExtrudeGeometry(shape, {
depth: D, bevelEnabled: true, bevelThickness: 0.07, bevelSize: 0.04, bevelSegments: 6,
});
const mat = new THREE.MeshPhysicalMaterial({
color, metalness: 0.15, roughness: 0.35, clearcoat: 0.15, side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI / 2;
mesh.position.y = -D / 2;
mesh.userData = { holding: h, baseY: -D / 2, color, pct: (h.value / total * 100).toFixed(1) };
mesh.castShadow = true;
group.add(mesh);
meshes.push(mesh);
angle += arc;
});
scene.add(group);
scene.add(Object.assign(new THREE.AmbientLight(0xffffff, 0.55)));
const dir = new THREE.DirectionalLight(0xffffff, 1.1);
dir.position.set(5, 10, 7); dir.castShadow = true; scene.add(dir);
const fill = new THREE.DirectionalLight(0x8b9ffc, 0.4);
fill.position.set(-5, 2, -5); scene.add(fill);
renderer.domElement.addEventListener('pointermove', e => {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(meshes);
if (hits.length && hits[0].object.userData.holding) {
const m = hits[0].object;
const h = m.userData.holding;
tip.textContent = `${h.name}: €${(h.value/100).toLocaleString('pt-PT',{minimumFractionDigits:2})} (${m.userData.pct}%)`;
tip.style.opacity = '1';
tip.style.left = (e.clientX - rect.left + 14) + 'px';
tip.style.top = (e.clientY - rect.top - 10) + 'px';
if (hovered !== m) {
if (hovered) { hovered.position.y = hovered.userData.baseY; }
m.position.y = m.userData.baseY + 0.25;
hovered = m;
}
} else {
tip.style.opacity = '0';
if (hovered) { hovered.position.y = hovered.userData.baseY; hovered = null; }
}
});
(function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
})();
window.addEventListener('resize', () => {
const w2 = container.clientWidth;
camera.aspect = w2 / H;
camera.updateProjectionMatrix();
renderer.setSize(w2, H);
});
} // end if (total > 0)
</script>
{{else}}
<div class="card empty-state animate-on-scroll">
<div style="font-size:48px; margin-bottom:16px;">📈</div>
<h3>{{$d.T.Get "portfolio.empty.title"}}</h3>
<p style="margin-bottom:20px;">{{$d.T.Get "portfolio.empty.desc"}}</p>
<a href="/import" class="btn btn-primary">{{$d.T.Get "portfolio.empty.btn_import"}}</a>
</div>
{{end}}
{{end}}