Gonçalo Rodrigues c255d7f523 fix(finance): portfolio prices and projections template
Portfolio:
- fetchPrices was passing ISINs directly to Yahoo Finance, which only
  accepts ticker symbols (e.g. VWCE.DE, not IE00B3RBWM25). Added a
  hardcoded isinToTicker map covering common European ETFs (Vanguard,
  iShares, Xtrackers, Amundi). fetchPricesByISIN now resolves each ISIN
  to its ticker before calling Yahoo and keys the result by ISIN so
  computeHoldings can look up prices correctly. Renamed holdingsByISIN
  to uniqueISINs to reflect what it actually returns.

Projections:
- Template had a missing <tr> and a broken pace-bar width expression
  that mixed float64/int64 in the div template function, causing a
  template execution error that rendered the page blank. Moved all
  math server-side: the handler now pre-computes []catProjection with
  MonthlyAvg, AnnualTotal, and PacePct already calculated. Template
  is now simple range + printf. Chart switched to horizontal bar for
  better readability of long category names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 12:47:03 +01:00

114 lines
4.1 KiB
HTML

{{define "content"}}
{{$d := .}}
<h1 style="margin-bottom:24px;">Projections</h1>
{{if $d.Projections}}
<div class="grid">
<div class="card value-card animate-on-scroll">
<h2>Projected Annual Spend</h2>
<div class="value negative animate-counter" data-target="{{$d.AnnualTotal}}" data-prefix="€">€0.00</div>
<p class="text-muted" style="margin-top:8px; font-size:12px;">Based on 6-month average</p>
</div>
<div class="card value-card animate-on-scroll">
<h2>Projected Monthly Spend</h2>
{{$monthly := div $d.AnnualTotal 12}}
<div class="value negative animate-counter" data-target="{{$monthly}}" data-prefix="€">€0.00</div>
<p class="text-muted" style="margin-top:8px; font-size:12px;">Average across all categories</p>
</div>
</div>
<div class="card animate-on-scroll">
<h2 style="margin-bottom:16px;">Monthly Average by Category — Last 6 Months</h2>
<div style="padding-top:4px;">
<canvas id="projChart" height="250"></canvas>
</div>
</div>
<div class="card animate-on-scroll">
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Category</th>
<th class="text-right">Monthly Avg</th>
<th class="text-right">Projected Annual</th>
<th style="width:180px;">Share of Spend</th>
</tr>
</thead>
<tbody>
{{range $d.Projections}}
{{$color := index $d.CategoryColors .Name}}
<tr>
<td>
<span style="display:inline-flex; align-items:center; gap:7px;">
{{if $color}}<span style="width:9px;height:9px;border-radius:50%;background:{{$color}};box-shadow:0 0 5px {{$color}}88;flex-shrink:0;"></span>{{end}}
<span style="font-weight:500;">{{.Name}}</span>
</span>
</td>
<td class="cents negative">€{{printf "%.2f" .MonthlyAvg}}</td>
<td class="cents negative">€{{printf "%.2f" .AnnualTotal}}</td>
<td>
<div style="display:flex; align-items:center; gap:8px;">
<div style="flex:1; background:var(--bg3); border-radius:6px; height:7px; overflow:hidden;">
<div style="height:100%; border-radius:6px; width:{{.PacePct}}%;
background:{{if $color}}{{$color}}{{else}}var(--accent){{end}};
box-shadow:{{if $color}}0 0 5px {{$color}}66{{else}}0 0 5px var(--accent-glow){{end}};"></div>
</div>
<span style="font-size:11.5px; color:var(--text3); min-width:30px; text-align:right;">{{.PacePct}}%</span>
</div>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<script>
const isDark = () => document.documentElement.getAttribute('data-theme') === 'dark';
const gridC = () => isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)';
const textC = () => isDark() ? '#5c6585' : '#8a92b0';
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
const labels = [{{range $d.Projections}}"{{.Name}}",{{end}}];
const data = [{{range $d.Projections}}{{printf "%.2f" .MonthlyAvg}},{{end}}];
const colors = labels.map(k => catColors[k] || '#6979f8');
new Chart(document.getElementById('projChart'), {
type: 'bar',
data: {
labels,
datasets: [{
label: 'Monthly Avg (€)',
data,
backgroundColor: colors.map(c => c + '99'),
borderColor: colors,
borderWidth: 1.5,
borderRadius: 6,
borderSkipped: false,
}]
},
options: {
indexAxis: 'y',
responsive: true,
animation: { duration: 900, easing: 'easeOutQuart' },
plugins: { legend: { display: false } },
scales: {
x: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } },
y: { grid: { display: false }, ticks: { color: textC() } }
}
}
});
</script>
{{else}}
<div class="card empty-state animate-on-scroll">
<div style="font-size:48px; margin-bottom:16px;">📊</div>
<h3>No spending data yet</h3>
<p>Import at least a month of transactions to see projections.</p>
<a href="/import" class="btn btn-primary" style="margin-top:20px;">Import Transactions</a>
</div>
{{end}}
{{end}}