When a holding has no price (ISIN not in the built-in map and Yahoo rejects the raw ISIN), the portfolio page now shows an amber banner listing each missing ISIN with an inline text input and a "Look up" link to Yahoo Finance symbol search. Submitting the form POSTs to /portfolio/ticker which upserts the mapping into a finance_ticker_mappings collection keyed by (user_id, ISIN). On the next page load custom mappings are resolved first, before the hardcoded isinToTicker table, so user overrides always win. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
245 lines
9.4 KiB
HTML
245 lines
9.4 KiB
HTML
{{define "content"}}
|
||
{{$d := .}}
|
||
<h1 style="margin-bottom:24px;">Portfolio</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;">⚠ Live price unavailable for {{len $d.MissingPrices}} holding{{if gt (len $d.MissingPrices) 1}}s{{end}} — add a Yahoo Finance ticker to fix this</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="e.g. QDVE.DE" 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;">Save</button>
|
||
<a href="https://finance.yahoo.com/lookup/" target="_blank" rel="noopener"
|
||
style="font-size:0.78rem; color:var(--accent);">Look up ↗</a>
|
||
</form>
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
|
||
<div class="grid">
|
||
<div class="card value-card animate-on-scroll">
|
||
<h2>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>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>Unrealized P&L</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;">Allocation</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;">Holdings</h2>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Asset</th>
|
||
<th class="text-right">Shares</th>
|
||
<th class="text-right">Avg Cost</th>
|
||
<th class="text-right">Price</th>
|
||
<th class="text-right">Value</th>
|
||
<th class="text-right">P&L</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">Add trades via</span>
|
||
<a href="/import" class="btn btn-outline btn-sm">Import CSV</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 = [
|
||
'#6979f8','#f87171','#fbbf24','#34d399','#a78bfa',
|
||
'#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9',
|
||
];
|
||
|
||
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;
|
||
|
||
// tooltip
|
||
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>No trades yet</h3>
|
||
<p style="margin-bottom:20px;">Import your Trade Republic securities CSV to see your portfolio.</p>
|
||
<a href="/import" class="btn btn-primary">Import Trades</a>
|
||
</div>
|
||
{{end}}
|
||
{{end}}
|