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>
This commit is contained in:
Gonçalo Rodrigues 2026-06-13 12:47:03 +01:00
parent 2de6e4d4a7
commit c255d7f523
4 changed files with 130 additions and 40 deletions

View File

@ -933,6 +933,41 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) {
}
annualTotal := int64(math.Round(float64(-totalSpend) / float64(monthCount) * 12))
monthlyTotal := float64(annualTotal) / 12
// pre-compute pace percentage per category for the template (avoids float/int type issues)
type catProjection struct {
Name string
MonthlyAvg float64
AnnualTotal float64
PacePct int
}
cats2, _ := h.store.getCategories(ctx, a.UserID)
catColors2 := make(map[string]string)
for _, c := range cats2 {
catColors2[c.Name] = c.Color
}
var projections []catProjection
for cat, avg := range monthlyAvg {
pct := 0
if monthlyTotal > 0 {
pct = int(math.Round(avg / monthlyTotal * 100))
if pct > 100 {
pct = 100
}
}
projections = append(projections, catProjection{
Name: cat,
MonthlyAvg: avg,
AnnualTotal: avg * 12,
PacePct: pct,
})
}
// sort by monthly avg descending
sort.Slice(projections, func(i, j int) bool {
return projections[i].MonthlyAvg > projections[j].MonthlyAvg
})
render(w, projectionsTmpl, map[string]interface{}{
"UserID": a.UserID,
@ -940,9 +975,9 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) {
"Title": "Projections",
"Route": "projections",
"IsOwner": true,
"MonthlyAvg": monthlyAvg,
"Projections": projections,
"AnnualTotal": annualTotal,
"CategoryNames": catNames,
"CategoryColors": catColors2,
})
}
@ -957,8 +992,8 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
return
}
tickers := holdingsByISIN(trades)
if len(tickers) == 0 {
isins := uniqueISINs(trades)
if len(isins) == 0 {
render(w, portfolioTmpl, &PortfolioData{
UserID: a.UserID,
Email: a.Email,
@ -968,7 +1003,7 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
return
}
prices, err := fetchPrices(tickers)
prices, err := fetchPricesByISIN(isins)
if err != nil {
slog.Error("fetch prices", "err", err)
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"sort"
@ -15,6 +16,30 @@ type TickerMapping struct {
Ticker string `bson:"ticker" json:"ticker"`
}
// isinToTicker maps well-known ISIN codes to their Yahoo Finance ticker symbols.
// Add entries here for any ETF/stock you trade that isn't covered yet.
var isinToTicker = map[string]string{
// Vanguard
"IE00B3RBWM25": "VWCE.DE", // VWCE — All-World
"IE00B3XXRP09": "VWRL.AS", // VWRL — All-World
"IE00BK5BQT80": "VWRA.L", // VWRA — All-World (acc, LSE)
// iShares
"IE00B4L5Y983": "EUNL.DE", // EUNL — MSCI World
"IE00B5BMR087": "SXR8.DE", // SXR8 — S&P 500
"IE00B4K48X80": "SXRV.DE", // SXRV — S&P 500 EUR hedged
"IE00B52MJY50": "IUSA.AS", // IUSA — S&P 500
"IE00B0M63177": "IWRD.AS", // IWRD — MSCI World
"IE00B4ND3602": "EMIM.AS", // EMIM — Emerging Markets
"IE00BKM4GZ66": "IS3N.DE", // IS3N — Core MSCI EM
"IE00B4L5YC18": "CSSPX.MI", // CSSPX — Core S&P 500
// Xtrackers
"LU0274208692": "DBXW.DE", // DBXW — MSCI World
"LU0490618542": "XMWO.DE", // XMWO — MSCI World Swap
// Amundi
"LU1681043599": "CW8.PA", // CW8 — MSCI World
"FR0010315770": "CU2.PA", // CU2 — S&P 500
}
type yahooChartResponse struct {
Chart struct {
Result []struct {
@ -124,21 +149,27 @@ type TickerStore interface {
Load() error
}
func fetchPrices(tickers []string) (map[string]int64, error) {
if len(tickers) == 0 {
// fetchPricesByISIN resolves each ISIN to a Yahoo Finance ticker (via isinToTicker),
// fetches the current market price, and returns a map keyed by ISIN.
// ISINs with no known ticker mapping are tried directly as a ticker (fallback).
func fetchPricesByISIN(isins []string) (map[string]int64, error) {
if len(isins) == 0 {
return map[string]int64{}, nil
}
result := make(map[string]int64)
client := &http.Client{Timeout: 10 * time.Second}
for _, ticker := range tickers {
if ticker == "" {
continue
for _, isin := range isins {
ticker, ok := isinToTicker[isin]
if !ok {
ticker = isin // last-resort fallback
}
url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s", ticker)
resp, err := client.Get(url)
if err != nil {
slog.Warn("price fetch failed", "isin", isin, "ticker", ticker, "err", err)
continue
}
body, err := io.ReadAll(resp.Body)
@ -154,14 +185,17 @@ func fetchPrices(tickers []string) (map[string]int64, error) {
if len(chart.Chart.Result) > 0 {
price := chart.Chart.Result[0].Meta.RegularMarketPrice
result[ticker] = int64(price * 100)
if price > 0 {
result[isin] = int64(price * 100)
slog.Info("price fetched", "isin", isin, "ticker", ticker, "price_cents", result[isin])
}
}
}
return result, nil
}
func holdingsByISIN(trades []Trade) []string {
func uniqueISINs(trades []Trade) []string {
seen := make(map[string]bool)
var result []string
for _, t := range trades {

View File

@ -133,7 +133,7 @@ func TestHoldingsByISIN(t *testing.T) {
{ISIN: "US0378331005"},
{ISIN: "IE00B0M62X35"},
}
got := holdingsByISIN(trades)
got := uniqueISINs(trades)
want := []string{"IE00B0M62X35", "US0378331005"}
if len(got) != len(want) {
t.Fatalf("got %d isins, want %d: %v", len(got), len(want), got)

View File

@ -2,24 +2,26 @@
{{$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;">Based on 6-month average</p>
<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;">Average across categories</p>
<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:12px;">Monthly Average by Category — Last 6 Months</h2>
<h2 style="margin-bottom:16px;">Monthly Average by Category — Last 6 Months</h2>
<div style="padding-top:4px;">
<canvas id="projChart" height="260"></canvas>
<canvas id="projChart" height="250"></canvas>
</div>
</div>
@ -31,21 +33,31 @@
<th>Category</th>
<th class="text-right">Monthly Avg</th>
<th class="text-right">Projected Annual</th>
<th style="width:200px;">Pace</th>
<th style="width:180px;">Share of Spend</th>
</tr>
</thead>
<tbody>
{{range $cat, $avg := $d.MonthlyAvg}}
<td style="font-weight:500;">{{$cat}}</td>
<td class="cents negative">€{{printf "%.2f" $avg}}</td>
<td class="cents negative">€{{printf "%.2f" (mul $avg 12)}}</td>
<td>
<div style="background:var(--bg3); border-radius:6px; height:6px; overflow:hidden;">
<div style="height:100%; border-radius:6px; background:var(--accent);
width:{{round (mul (div (round (mul $avg 100)) (div $d.AnnualTotal 12)) 100)}}%;
max-width:100%;"></div>
</div>
</td>
{{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>
@ -58,35 +70,44 @@ const isDark = () => document.documentElement.getAttribute('data-theme') === 'da
const gridC = () => isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)';
const textC = () => isDark() ? '#5c6585' : '#8a92b0';
const catColors = [
'#6979f8','#f87171','#fbbf24','#34d399','#a78bfa',
'#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9',
'#94a3b8','#22d3ee','#f97316','#a3e635',
];
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: [{{range $cat, $_ := $d.MonthlyAvg}}"{{$cat}}",{{end}}],
labels,
datasets: [{
label: 'Monthly Avg (€)',
data: [{{range $_, $avg := $d.MonthlyAvg}}{{$avg}},{{end}}],
backgroundColor: catColors.map(c => c + '99'),
borderColor: catColors,
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: {
y: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } },
x: { grid: { display: false }, ticks: { color: textC() } }
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}}