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)) 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{}{ render(w, projectionsTmpl, map[string]interface{}{
"UserID": a.UserID, "UserID": a.UserID,
@ -940,9 +975,9 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) {
"Title": "Projections", "Title": "Projections",
"Route": "projections", "Route": "projections",
"IsOwner": true, "IsOwner": true,
"MonthlyAvg": monthlyAvg, "Projections": projections,
"AnnualTotal": annualTotal, "AnnualTotal": annualTotal,
"CategoryNames": catNames, "CategoryColors": catColors2,
}) })
} }
@ -957,8 +992,8 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
return return
} }
tickers := holdingsByISIN(trades) isins := uniqueISINs(trades)
if len(tickers) == 0 { if len(isins) == 0 {
render(w, portfolioTmpl, &PortfolioData{ render(w, portfolioTmpl, &PortfolioData{
UserID: a.UserID, UserID: a.UserID,
Email: a.Email, Email: a.Email,
@ -968,7 +1003,7 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
return return
} }
prices, err := fetchPrices(tickers) prices, err := fetchPricesByISIN(isins)
if err != nil { if err != nil {
slog.Error("fetch prices", "err", err) slog.Error("fetch prices", "err", err)
} }

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"math" "math"
"net/http" "net/http"
"sort" "sort"
@ -15,6 +16,30 @@ type TickerMapping struct {
Ticker string `bson:"ticker" json:"ticker"` 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 { type yahooChartResponse struct {
Chart struct { Chart struct {
Result []struct { Result []struct {
@ -124,21 +149,27 @@ type TickerStore interface {
Load() error Load() error
} }
func fetchPrices(tickers []string) (map[string]int64, error) { // fetchPricesByISIN resolves each ISIN to a Yahoo Finance ticker (via isinToTicker),
if len(tickers) == 0 { // 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 return map[string]int64{}, nil
} }
result := make(map[string]int64) result := make(map[string]int64)
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
for _, ticker := range tickers { for _, isin := range isins {
if ticker == "" { ticker, ok := isinToTicker[isin]
continue if !ok {
ticker = isin // last-resort fallback
} }
url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s", ticker) url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s", ticker)
resp, err := client.Get(url) resp, err := client.Get(url)
if err != nil { if err != nil {
slog.Warn("price fetch failed", "isin", isin, "ticker", ticker, "err", err)
continue continue
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
@ -154,14 +185,17 @@ func fetchPrices(tickers []string) (map[string]int64, error) {
if len(chart.Chart.Result) > 0 { if len(chart.Chart.Result) > 0 {
price := chart.Chart.Result[0].Meta.RegularMarketPrice 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 return result, nil
} }
func holdingsByISIN(trades []Trade) []string { func uniqueISINs(trades []Trade) []string {
seen := make(map[string]bool) seen := make(map[string]bool)
var result []string var result []string
for _, t := range trades { for _, t := range trades {

View File

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

View File

@ -2,24 +2,26 @@
{{$d := .}} {{$d := .}}
<h1 style="margin-bottom:24px;">Projections</h1> <h1 style="margin-bottom:24px;">Projections</h1>
{{if $d.Projections}}
<div class="grid"> <div class="grid">
<div class="card value-card animate-on-scroll"> <div class="card value-card animate-on-scroll">
<h2>Projected Annual Spend</h2> <h2>Projected Annual Spend</h2>
<div class="value negative animate-counter" data-target="{{$d.AnnualTotal}}" data-prefix="€">€0.00</div> <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>
<div class="card value-card animate-on-scroll"> <div class="card value-card animate-on-scroll">
<h2>Projected Monthly Spend</h2> <h2>Projected Monthly Spend</h2>
{{$monthly := div $d.AnnualTotal 12}} {{$monthly := div $d.AnnualTotal 12}}
<div class="value negative animate-counter" data-target="{{$monthly}}" data-prefix="€">€0.00</div> <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> </div>
<div class="card animate-on-scroll"> <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;"> <div style="padding-top:4px;">
<canvas id="projChart" height="260"></canvas> <canvas id="projChart" height="250"></canvas>
</div> </div>
</div> </div>
@ -31,21 +33,31 @@
<th>Category</th> <th>Category</th>
<th class="text-right">Monthly Avg</th> <th class="text-right">Monthly Avg</th>
<th class="text-right">Projected Annual</th> <th class="text-right">Projected Annual</th>
<th style="width:200px;">Pace</th> <th style="width:180px;">Share of Spend</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range $cat, $avg := $d.MonthlyAvg}} {{range $d.Projections}}
<td style="font-weight:500;">{{$cat}}</td> {{$color := index $d.CategoryColors .Name}}
<td class="cents negative">€{{printf "%.2f" $avg}}</td> <tr>
<td class="cents negative">€{{printf "%.2f" (mul $avg 12)}}</td> <td>
<td> <span style="display:inline-flex; align-items:center; gap:7px;">
<div style="background:var(--bg3); border-radius:6px; height:6px; overflow:hidden;"> {{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}}
<div style="height:100%; border-radius:6px; background:var(--accent); <span style="font-weight:500;">{{.Name}}</span>
width:{{round (mul (div (round (mul $avg 100)) (div $d.AnnualTotal 12)) 100)}}%; </span>
max-width:100%;"></div> </td>
</div> <td class="cents negative">€{{printf "%.2f" .MonthlyAvg}}</td>
</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> </tr>
{{end}} {{end}}
</tbody> </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 gridC = () => isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)';
const textC = () => isDark() ? '#5c6585' : '#8a92b0'; const textC = () => isDark() ? '#5c6585' : '#8a92b0';
const catColors = [ const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
'#6979f8','#f87171','#fbbf24','#34d399','#a78bfa', const labels = [{{range $d.Projections}}"{{.Name}}",{{end}}];
'#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9', const data = [{{range $d.Projections}}{{printf "%.2f" .MonthlyAvg}},{{end}}];
'#94a3b8','#22d3ee','#f97316','#a3e635', const colors = labels.map(k => catColors[k] || '#6979f8');
];
new Chart(document.getElementById('projChart'), { new Chart(document.getElementById('projChart'), {
type: 'bar', type: 'bar',
data: { data: {
labels: [{{range $cat, $_ := $d.MonthlyAvg}}"{{$cat}}",{{end}}], labels,
datasets: [{ datasets: [{
label: 'Monthly Avg (€)', label: 'Monthly Avg (€)',
data: [{{range $_, $avg := $d.MonthlyAvg}}{{$avg}},{{end}}], data,
backgroundColor: catColors.map(c => c + '99'), backgroundColor: colors.map(c => c + '99'),
borderColor: catColors, borderColor: colors,
borderWidth: 1.5, borderWidth: 1.5,
borderRadius: 6, borderRadius: 6,
borderSkipped: false, borderSkipped: false,
}] }]
}, },
options: { options: {
indexAxis: 'y',
responsive: true, responsive: true,
animation: { duration: 900, easing: 'easeOutQuart' }, animation: { duration: 900, easing: 'easeOutQuart' },
plugins: { legend: { display: false } }, plugins: { legend: { display: false } },
scales: { scales: {
y: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } }, x: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } },
x: { grid: { display: false }, ticks: { color: textC() } } y: { grid: { display: false }, ticks: { color: textC() } }
} }
} }
}); });
</script> </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}} {{end}}