Merge pull request #18 from GoncaloRodri/feature/nav-cleanup
Feature/nav cleanup
This commit is contained in:
commit
b1c9609f42
@ -186,6 +186,8 @@ type storeIface interface {
|
|||||||
updateGoal(ctx context.Context, id, userID string, update bson.M) error
|
updateGoal(ctx context.Context, id, userID string, update bson.M) error
|
||||||
deleteGoal(ctx context.Context, id, userID string) error
|
deleteGoal(ctx context.Context, id, userID string) error
|
||||||
seedCategories(ctx context.Context, userID string) error
|
seedCategories(ctx context.Context, userID string) error
|
||||||
|
getTickerMappings(ctx context.Context, userID string) ([]TickerMapping, error)
|
||||||
|
saveTickerMapping(ctx context.Context, userID, isin, ticker string) error
|
||||||
getHousehold(ctx context.Context, userID string) (*Household, error)
|
getHousehold(ctx context.Context, userID string) (*Household, error)
|
||||||
createHousehold(ctx context.Context, h *Household) error
|
createHousehold(ctx context.Context, h *Household) error
|
||||||
deleteHousehold(ctx context.Context, userID string) error
|
deleteHousehold(ctx context.Context, userID string) error
|
||||||
@ -460,7 +462,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
var portfolioHoldings []Holding
|
var portfolioHoldings []Holding
|
||||||
var portfolioPricesAvailable bool
|
var portfolioPricesAvailable bool
|
||||||
if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 {
|
if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 {
|
||||||
prices, _ := fetchPricesByISIN(uniqueISINs(trades))
|
prices, _ := fetchPricesByISIN(uniqueISINs(trades), nil)
|
||||||
holdings := computeHoldings(trades, prices)
|
holdings := computeHoldings(trades, prices)
|
||||||
pr := aggregatePortfolio(holdings)
|
pr := aggregatePortfolio(holdings)
|
||||||
portfolioHoldings = pr.Holdings
|
portfolioHoldings = pr.Holdings
|
||||||
@ -1330,11 +1332,26 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prices, err := fetchPricesByISIN(isins)
|
// load user-saved ISIN→ticker overrides
|
||||||
|
customMappings, _ := h.store.getTickerMappings(ctx, a.UserID)
|
||||||
|
custom := make(map[string]string, len(customMappings))
|
||||||
|
for _, m := range customMappings {
|
||||||
|
custom[m.ISIN] = m.Ticker
|
||||||
|
}
|
||||||
|
|
||||||
|
prices, err := fetchPricesByISIN(isins, custom)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("fetch prices", "err", err)
|
slog.Error("fetch prices", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// collect ISINs for which we got no price
|
||||||
|
var missingPrices []string
|
||||||
|
for _, isin := range isins {
|
||||||
|
if prices[isin] == 0 {
|
||||||
|
missingPrices = append(missingPrices, isin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
holdings := computeHoldings(trades, prices)
|
holdings := computeHoldings(trades, prices)
|
||||||
pr := aggregatePortfolio(holdings)
|
pr := aggregatePortfolio(holdings)
|
||||||
|
|
||||||
@ -1348,9 +1365,27 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
|
|||||||
TotalCostCents: pr.TotalCost,
|
TotalCostCents: pr.TotalCost,
|
||||||
TotalPCLCents: pr.TotalPCL,
|
TotalPCLCents: pr.TotalPCL,
|
||||||
TotalPCLPct: pr.PCLPct,
|
TotalPCLPct: pr.PCLPct,
|
||||||
|
MissingPrices: missingPrices,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SaveTickerMapping(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
a := getAuth(r)
|
||||||
|
isin := strings.TrimSpace(r.FormValue("isin"))
|
||||||
|
ticker := strings.TrimSpace(r.FormValue("ticker"))
|
||||||
|
if isin == "" || ticker == "" {
|
||||||
|
http.Error(w, "isin and ticker required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.store.saveTickerMapping(ctx, a.UserID, isin, ticker); err != nil {
|
||||||
|
slog.Error("save ticker mapping", "err", err)
|
||||||
|
http.Error(w, "save error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/portfolio", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) ImportSecurities(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ImportSecurities(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
a := getAuth(r)
|
a := getAuth(r)
|
||||||
@ -1884,7 +1919,7 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
|
|||||||
var portfolioCents int64
|
var portfolioCents int64
|
||||||
var pricesAvailable bool
|
var pricesAvailable bool
|
||||||
if trades, err2 := h.store.getTrades(ctx, a.UserID); err2 == nil && len(trades) > 0 {
|
if trades, err2 := h.store.getTrades(ctx, a.UserID); err2 == nil && len(trades) > 0 {
|
||||||
prices, _ := fetchPricesByISIN(uniqueISINs(trades))
|
prices, _ := fetchPricesByISIN(uniqueISINs(trades), nil)
|
||||||
holdings := computeHoldings(trades, prices)
|
holdings := computeHoldings(trades, prices)
|
||||||
pr := aggregatePortfolio(holdings)
|
pr := aggregatePortfolio(holdings)
|
||||||
for _, p := range prices {
|
for _, p := range prices {
|
||||||
@ -2414,6 +2449,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /import/preview", h.ImportPreview)
|
mux.HandleFunc("POST /import/preview", h.ImportPreview)
|
||||||
mux.HandleFunc("POST /import/confirm", h.ImportConfirm)
|
mux.HandleFunc("POST /import/confirm", h.ImportConfirm)
|
||||||
mux.HandleFunc("POST /import/securities", h.ImportSecurities)
|
mux.HandleFunc("POST /import/securities", h.ImportSecurities)
|
||||||
|
mux.HandleFunc("POST /portfolio/ticker", h.SaveTickerMapping)
|
||||||
mux.HandleFunc("POST /accounts", h.Accounts)
|
mux.HandleFunc("POST /accounts", h.Accounts)
|
||||||
mux.HandleFunc("DELETE /accounts/{id}", h.Accounts)
|
mux.HandleFunc("DELETE /accounts/{id}", h.Accounts)
|
||||||
mux.HandleFunc("POST /categories", h.Categories)
|
mux.HandleFunc("POST /categories", h.Categories)
|
||||||
|
|||||||
@ -154,6 +154,11 @@ func (m *mockStore) deleteGoal(_ context.Context, id, _ string) error {
|
|||||||
}
|
}
|
||||||
func (m *mockStore) seedCategories(_ context.Context, _ string) error { return nil }
|
func (m *mockStore) seedCategories(_ context.Context, _ string) error { return nil }
|
||||||
|
|
||||||
|
func (m *mockStore) getTickerMappings(_ context.Context, _ string) ([]TickerMapping, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) saveTickerMapping(_ context.Context, _, _, _ string) error { return nil }
|
||||||
|
|
||||||
func (m *mockStore) getHousehold(_ context.Context, _ string) (*Household, error) {
|
func (m *mockStore) getHousehold(_ context.Context, _ string) (*Household, error) {
|
||||||
return nil, fmt.Errorf("not found")
|
return nil, fmt.Errorf("not found")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -207,6 +207,8 @@ type PortfolioData struct {
|
|||||||
TotalPCLCents int64
|
TotalPCLCents int64
|
||||||
TotalPCLPct float64
|
TotalPCLPct float64
|
||||||
RealizedPCLCents int64
|
RealizedPCLCents int64
|
||||||
|
// ISINs for which no price could be fetched (so user can supply a ticker)
|
||||||
|
MissingPrices []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SharingData struct {
|
type SharingData struct {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
type TickerMapping struct {
|
type TickerMapping struct {
|
||||||
ISIN string `bson:"_id" json:"isin"`
|
ISIN string `bson:"_id" json:"isin"`
|
||||||
|
UserID string `bson:"user_id" json:"user_id"`
|
||||||
Ticker string `bson:"ticker" json:"ticker"`
|
Ticker string `bson:"ticker" json:"ticker"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,6 +25,7 @@ var isinToTicker = map[string]string{
|
|||||||
"IE00B3XXRP09": "VWRL.AS", // VWRL — All-World
|
"IE00B3XXRP09": "VWRL.AS", // VWRL — All-World
|
||||||
"IE00BK5BQT80": "VWRA.L", // VWRA — All-World (acc, LSE)
|
"IE00BK5BQT80": "VWRA.L", // VWRA — All-World (acc, LSE)
|
||||||
// iShares
|
// iShares
|
||||||
|
"IE00B3WJKG14": "QDVE.DE", // QDVE — S&P 500 Information Technology
|
||||||
"IE00B4L5Y983": "EUNL.DE", // EUNL — MSCI World
|
"IE00B4L5Y983": "EUNL.DE", // EUNL — MSCI World
|
||||||
"IE00B5BMR087": "SXR8.DE", // SXR8 — S&P 500
|
"IE00B5BMR087": "SXR8.DE", // SXR8 — S&P 500
|
||||||
"IE00B4K48X80": "SXRV.DE", // SXRV — S&P 500 EUR hedged
|
"IE00B4K48X80": "SXRV.DE", // SXRV — S&P 500 EUR hedged
|
||||||
@ -105,7 +107,7 @@ func computeHoldings(trades []Trade, prices map[string]int64) []Holding {
|
|||||||
|
|
||||||
currentPrice := prices[isin]
|
currentPrice := prices[isin]
|
||||||
|
|
||||||
currentValue := int64(float64(currentPrice) * a.shares / 100)
|
currentValue := int64(float64(currentPrice) * a.shares)
|
||||||
unrealizedPCL := currentValue - a.cost
|
unrealizedPCL := currentValue - a.cost
|
||||||
pct := 0.0
|
pct := 0.0
|
||||||
if a.cost > 0 {
|
if a.cost > 0 {
|
||||||
@ -149,10 +151,10 @@ type TickerStore interface {
|
|||||||
Load() error
|
Load() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchPricesByISIN resolves each ISIN to a Yahoo Finance ticker (via isinToTicker),
|
// fetchPricesByISIN resolves each ISIN to a Yahoo Finance ticker, checking
|
||||||
// fetches the current market price, and returns a map keyed by ISIN.
|
// custom (user-saved) mappings first, then the hardcoded isinToTicker map,
|
||||||
// ISINs with no known ticker mapping are tried directly as a ticker (fallback).
|
// then falling back to the raw ISIN as a last resort.
|
||||||
func fetchPricesByISIN(isins []string) (map[string]int64, error) {
|
func fetchPricesByISIN(isins []string, custom map[string]string) (map[string]int64, error) {
|
||||||
if len(isins) == 0 {
|
if len(isins) == 0 {
|
||||||
return map[string]int64{}, nil
|
return map[string]int64{}, nil
|
||||||
}
|
}
|
||||||
@ -161,13 +163,21 @@ func fetchPricesByISIN(isins []string) (map[string]int64, error) {
|
|||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
for _, isin := range isins {
|
for _, isin := range isins {
|
||||||
ticker, ok := isinToTicker[isin]
|
ticker := custom[isin]
|
||||||
if !ok {
|
if ticker == "" {
|
||||||
|
ticker = isinToTicker[isin]
|
||||||
|
}
|
||||||
|
if ticker == "" {
|
||||||
ticker = isin // last-resort fallback
|
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)
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; homelab-finance/1.0)")
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("price fetch failed", "isin", isin, "ticker", ticker, "err", err)
|
slog.Warn("price fetch failed", "isin", isin, "ticker", ticker, "err", err)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@ -328,6 +328,36 @@ var defaultCategories = []struct {
|
|||||||
{"Other", "#9E9E9E"},
|
{"Other", "#9E9E9E"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) tickerMappings() *mgmongo.Collection {
|
||||||
|
return s.db.Collection("finance_ticker_mappings")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getTickerMappings(ctx context.Context, userID string) ([]TickerMapping, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getTickerMappings")
|
||||||
|
defer span.End()
|
||||||
|
cur, err := s.tickerMappings().Find(ctx, bson.M{"user_id": userID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer cur.Close(ctx)
|
||||||
|
var items []TickerMapping
|
||||||
|
if err := cur.All(ctx, &items); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) saveTickerMapping(ctx context.Context, userID, isin, ticker string) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.saveTickerMapping")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.tickerMappings().UpdateOne(ctx,
|
||||||
|
bson.M{"_id": isin, "user_id": userID},
|
||||||
|
bson.M{"$set": bson.M{"ticker": ticker, "user_id": userID}},
|
||||||
|
options.UpdateOne().SetUpsert(true),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) households() *mgmongo.Collection {
|
func (s *Store) households() *mgmongo.Collection {
|
||||||
return s.db.Collection("finance_households")
|
return s.db.Collection("finance_households")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,51 +8,51 @@
|
|||||||
<style>
|
<style>
|
||||||
/* ── Tokens ─────────────────────────────────────────────────────── */
|
/* ── Tokens ─────────────────────────────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1117;
|
--bg: #080c10;
|
||||||
--bg2: #161b27;
|
--bg2: #0d1318;
|
||||||
--bg3: #1e2535;
|
--bg3: #131c24;
|
||||||
--surface: rgba(30, 37, 53, 0.85);
|
--surface: rgba(13, 22, 32, 0.88);
|
||||||
--surface2: rgba(40, 50, 72, 0.7);
|
--surface2: rgba(20, 32, 44, 0.75);
|
||||||
--border: rgba(255,255,255,0.07);
|
--border: rgba(0,210,200,0.08);
|
||||||
--border2: rgba(255,255,255,0.12);
|
--border2: rgba(0,210,200,0.15);
|
||||||
--text: #e8eaf6;
|
--text: #dff4f2;
|
||||||
--text2: #9fa8c7;
|
--text2: #7fb8b4;
|
||||||
--text3: #5c6585;
|
--text3: #3d6e6a;
|
||||||
--accent: #6979f8;
|
--accent: #00c9b8;
|
||||||
--accent2: #8b9ffc;
|
--accent2: #33d9ca;
|
||||||
--accent-glow: rgba(105,121,248,0.25);
|
--accent-glow: rgba(0,201,184,0.22);
|
||||||
--green: #4ade80;
|
--green: #00e5b0;
|
||||||
--red: #f87171;
|
--red: #f87171;
|
||||||
--green-dim: rgba(74,222,128,0.15);
|
--green-dim: rgba(0,229,176,0.12);
|
||||||
--red-dim: rgba(248,113,113,0.15);
|
--red-dim: rgba(248,113,113,0.13);
|
||||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.4);
|
||||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.5), 0 2px 6px rgba(0,0,0,0.3);
|
--shadow-md: 0 4px 16px rgba(0,0,0,0.6), 0 2px 6px rgba(0,0,0,0.4);
|
||||||
--shadow-lg: 0 12px 40px rgba(0,0,0,0.6), 0 4px 12px rgba(0,0,0,0.4);
|
--shadow-lg: 0 12px 40px rgba(0,0,0,0.7), 0 4px 12px rgba(0,0,0,0.5);
|
||||||
--radius: 14px;
|
--radius: 14px;
|
||||||
--radius-sm: 8px;
|
--radius-sm: 8px;
|
||||||
--nav-h: 58px;
|
--nav-h: 58px;
|
||||||
}
|
}
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--bg: #f0f2f8;
|
--bg: #edf6f5;
|
||||||
--bg2: #e4e8f4;
|
--bg2: #dceeed;
|
||||||
--bg3: #d8ddf0;
|
--bg3: #cae5e3;
|
||||||
--surface: rgba(255,255,255,0.9);
|
--surface: rgba(255,255,255,0.92);
|
||||||
--surface2: rgba(240,242,248,0.8);
|
--surface2: rgba(237,246,245,0.85);
|
||||||
--border: rgba(0,0,0,0.07);
|
--border: rgba(0,150,140,0.1);
|
||||||
--border2: rgba(0,0,0,0.12);
|
--border2: rgba(0,150,140,0.18);
|
||||||
--text: #1a1f36;
|
--text: #0d2422;
|
||||||
--text2: #4a5275;
|
--text2: #2a6460;
|
||||||
--text3: #8a92b0;
|
--text3: #6aadaa;
|
||||||
--accent: #4355e8;
|
--accent: #00897b;
|
||||||
--accent2: #6373f0;
|
--accent2: #00a896;
|
||||||
--accent-glow: rgba(67,85,232,0.18);
|
--accent-glow: rgba(0,137,123,0.15);
|
||||||
--green: #16a34a;
|
--green: #00796b;
|
||||||
--red: #dc2626;
|
--red: #dc2626;
|
||||||
--green-dim: rgba(22,163,74,0.1);
|
--green-dim: rgba(0,121,107,0.1);
|
||||||
--red-dim: rgba(220,38,38,0.1);
|
--red-dim: rgba(220,38,38,0.1);
|
||||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.05);
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
|
||||||
--shadow-md: 0 4px 16px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06);
|
--shadow-md: 0 4px 16px rgba(0,0,0,0.09), 0 2px 6px rgba(0,0,0,0.05);
|
||||||
--shadow-lg: 0 12px 40px rgba(0,0,0,0.12), 0 4px 12px rgba(0,0,0,0.07);
|
--shadow-lg: 0 12px 40px rgba(0,0,0,0.11), 0 4px 12px rgba(0,0,0,0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Reset & base ────────────────────────────────────────────────── */
|
/* ── Reset & base ────────────────────────────────────────────────── */
|
||||||
@ -74,8 +74,8 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(ellipse 80% 60% at 20% 10%, rgba(105,121,248,0.08) 0%, transparent 60%),
|
radial-gradient(ellipse 80% 60% at 20% 10%, rgba(0,201,184,0.07) 0%, transparent 60%),
|
||||||
radial-gradient(ellipse 60% 50% at 80% 80%, rgba(139,159,252,0.05) 0%, transparent 55%);
|
radial-gradient(ellipse 60% 50% at 80% 80%, rgba(0,150,140,0.04) 0%, transparent 55%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,23 @@
|
|||||||
|
|
||||||
{{if $d.Holdings}}
|
{{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="grid">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Total Value</h2>
|
<h2>Total Value</h2>
|
||||||
@ -93,16 +110,17 @@ import * as THREE from 'three';
|
|||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
const palette = [
|
const palette = [
|
||||||
'#6979f8','#f87171','#fbbf24','#34d399','#a78bfa',
|
'#00d4ff','#0099cc','#00b894','#007a63','#005f8a',
|
||||||
'#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9',
|
'#00e5cc','#0077b6','#48cae4','#023e8a','#00b4d8',
|
||||||
];
|
];
|
||||||
|
|
||||||
const holdings = [
|
const holdings = [
|
||||||
{{range $d.Holdings}}{ name: "{{.Name}}", value: {{.CurrentValueCents}} },{{end}}
|
{{range $d.Holdings}}{ name: "{{.Name}}", value: {{.CurrentValueCents}} },{{end}}
|
||||||
];
|
].filter(h => h.value > 0);
|
||||||
|
|
||||||
const total = holdings.reduce((s, h) => s + h.value, 0);
|
const total = holdings.reduce((s, h) => s + h.value, 0);
|
||||||
if (total <= 0) return;
|
|
||||||
|
if (total > 0) {
|
||||||
|
|
||||||
const container = document.getElementById('allocation3d');
|
const container = document.getElementById('allocation3d');
|
||||||
const W = container.clientWidth, H = 380;
|
const W = container.clientWidth, H = 380;
|
||||||
@ -211,6 +229,8 @@ window.addEventListener('resize', () => {
|
|||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
renderer.setSize(w2, H);
|
renderer.setSize(w2, H);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
} // end if (total > 0)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
BIN
apps/finance/services/api/main/testdata/.DS_Store
vendored
Normal file
BIN
apps/finance/services/api/main/testdata/.DS_Store
vendored
Normal file
Binary file not shown.
@ -1,6 +0,0 @@
|
|||||||
"datetime","date","account_type","category","type","asset_class","name","symbol","shares","price","amount","fee","tax","currency","original_amount","original_currency","fx_rate","description","transaction_id","counterparty_name","counterparty_iban","payment_reference","mcc_code"
|
|
||||||
"2025-12-11T08:08:30.375Z","2025-12-11","DEFAULT","TRADING","BUY","FUND","S&P 500 Information Tech USD (Acc)","IE00B3WJKG14","0.8289580000","36.1900000000","-30.00","-1.00","","EUR","","","","Buy trade IE00B3WJKG14 iShares S&P 500 Info Tech UCITS ETF USD (Acc), quantity: 0.828958","905f97ce-8c20-4891-8713-cb084b0cecb1","","","",""
|
|
||||||
"2025-12-11T08:09:16.732Z","2025-12-11","DEFAULT","TRADING","BUY","FUND","Core S&P 500 USD (Acc)","IE00B5BMR087","0.0287900000","625.1800000000","-18.00","-1.00","","EUR","","","","Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.02879","8bf8206a-a90e-4df4-98d5-3473dcf1c7d6","","","",""
|
|
||||||
"2026-02-26T07:53:08.309Z","2026-02-26","DEFAULT","TRADING","BUY","FUND","Core S&P 500 USD (Acc)","IE00B5BMR087","0.1586540000","630.3000000000","-100.00","-1.00","","EUR","","","","Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.158654","c154e2fd-7e13-43dc-b78f-48a0bf4b9fc6","","","",""
|
|
||||||
"2026-02-26T07:54:29.927Z","2026-02-26","DEFAULT","TRADING","BUY","FUND","Core MSCI World USD (Acc)","IE00B4L5Y983","0.8578050000","114.2500000000","-98.00","-1.00","","EUR","","","","Buy trade IE00B4L5Y983 iShares Core MSCI World UCITS ETF USD (Acc), quantity: 0.857805","7c79b9aa-82ce-48e7-bf93-20786b7977b5","","","",""
|
|
||||||
"2026-04-15T07:28:37.604Z","2026-04-15","DEFAULT","TRADING","BUY","FUND","Core S&P 500 USD (Acc)","IE00B5BMR087","0.1575100000","634.8800000000","-100.00","-1.00","","EUR","","","","Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.15751","4062d871-f968-483a-8f1f-16447c877201","","","",""
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user