Gonçalo Rodrigues 7a2cb10c79 feat(finance): dark mode UI overhaul + admin seed data
UI — full dark/light theme system:
- CSS custom-property token system (--bg, --surface, --accent, --green, --red, etc.)
  with complete light-mode overrides via [data-theme="light"]
- Sticky frosted-glass nav with animated brand icon, theme toggle persisted to localStorage,
  respects prefers-color-scheme on first visit
- Cards with layered shadows, glass backdrop-filter, shimmer accent stripe on value cards
- Glowing category color dots, colored P&L badges, budget bars with glow effect
- All Chart.js instances use CSS-variable-aware grid/text colours
- Scroll-reveal animations and animated money counters on every KPI card
- 3-D donut portfolio chart recoloured to match palette; hover lifts the hovered slice
- Accounts page shows type emoji icons; delete removes row in-place
- Sharing page search dropdown themed with var() colours
- Import preview: colour-coded left border on category select driven by category colour
- Projections: second KPI card (monthly avg) + pace bars per category

Seed data (seed.go):
- SeedAdmin() runs in a goroutine at startup; idempotent (skips if transactions exist)
- Resolves admin user ID via internal users service GET /admin/users?search=<email>
  (SEED_USER_EMAIL env var, defaults to admin@homelab.local)
- Seeds 4 accounts (CGD Checking, CGD Savings, Visa Credit, Trade Republic)
- Seeds 14 categories with colours and monthly budgets
- Seeds ~65 realistic Portuguese household transactions spread across 6 months
- Seeds 7 ETF buy trades across VWCE, SXR8 (S&P 500), EUNL (MSCI World)

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

315 lines
13 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// SeedAdmin looks up the admin user by email (via the internal users service)
// and seeds demo data if the account has no existing transactions.
func SeedAdmin(ctx context.Context, store *Store) {
email := os.Getenv("SEED_USER_EMAIL")
if email == "" {
email = "admin@homelab.local"
}
userID, err := lookupUserByEmail(email)
if err != nil {
slog.Warn("seed: could not resolve admin user, skipping", "email", email, "err", err)
return
}
// idempotent — skip if any transactions already exist
existing, err := store.getTransactions(ctx, userID, bson.M{})
if err == nil && len(existing) > 0 {
slog.Info("seed: data already present, skipping", "user_id", userID)
return
}
slog.Info("seed: seeding demo data", "user_id", userID, "email", email)
if err := seedAll(ctx, store, userID); err != nil {
slog.Error("seed: failed", "err", err)
} else {
slog.Info("seed: done")
}
}
func lookupUserByEmail(email string) (string, error) {
usersURL := os.Getenv("USERS_SERVICE_URL")
if usersURL == "" {
usersURL = "http://users"
}
resp, err := http.Get(fmt.Sprintf("%s/admin/users?search=%s", usersURL, email))
if err != nil {
return "", fmt.Errorf("users service unreachable: %w", err)
}
defer resp.Body.Close()
var users []struct {
ID string `json:"id"`
Email string `json:"email"`
}
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
return "", fmt.Errorf("decode users response: %w", err)
}
for _, u := range users {
if u.Email == email {
return u.ID, nil
}
}
return "", fmt.Errorf("user %q not found", email)
}
func seedAll(ctx context.Context, store *Store, userID string) error {
// ── Accounts ─────────────────────────────────────────────────────────
checkingID := bson.NewObjectID().Hex()
savingsID := bson.NewObjectID().Hex()
creditID := bson.NewObjectID().Hex()
investID := bson.NewObjectID().Hex()
accounts := []*Account{
{ID: checkingID, UserID: userID, Name: "CGD Checking", Type: "checking"},
{ID: savingsID, UserID: userID, Name: "CGD Savings", Type: "savings"},
{ID: creditID, UserID: userID, Name: "Visa Credit", Type: "credit"},
{ID: investID, UserID: userID, Name: "Trade Republic", Type: "securities"},
}
for _, a := range accounts {
if err := store.createAccount(ctx, a); err != nil {
return fmt.Errorf("create account: %w", err)
}
}
// ── Categories with budgets ───────────────────────────────────────────
type catDef struct {
Name string
Color string
BudgetCents int64
}
catDefs := []catDef{
{"Groceries", "#4caf50", 30000},
{"Food", "#ff9800", 20000},
{"Transport", "#2196f3", 8000},
{"Housing", "#9c27b0", 80000},
{"Utilities", "#607d8b", 10000},
{"Health", "#f44336", 5000},
{"Clothing", "#e91e63", 10000},
{"Games", "#673ab7", 3000},
{"Entertainment", "#ff5722", 5000},
{"Subscriptions", "#795548", 4000},
{"Shopping", "#ff6f00", 15000},
{"Income", "#2e7d32", 0},
{"Investments", "#1565c0", 20000},
{"Others", "#9e9e9e", 5000},
}
catIDByName := make(map[string]string)
for _, cd := range catDefs {
id := bson.NewObjectID().Hex()
catIDByName[cd.Name] = id
cat := &Category{
ID: id,
UserID: userID,
Name: cd.Name,
Color: cd.Color,
BudgetCents: cd.BudgetCents,
}
if err := store.createCategory(ctx, cat); err != nil {
return fmt.Errorf("create category %s: %w", cd.Name, err)
}
}
// ── Transactions — 6 months of realistic Portuguese household spend ───
now := time.Now()
var txns []Transaction
type txDef struct {
daysAgo int
desc string
amountCents int64
cat string
accountID string
}
rawTxns := []txDef{
// ── Month 0 (current month) ──────────────────────────────────────
{0, "Salary — Homelab Corp", 230000, "Income", checkingID},
{1, "Continente Supermercado", -4230, "Groceries", checkingID},
{2, "MEO Internet", -3999, "Utilities", checkingID},
{3, "Glovo — Sushi House", -2150, "Food", creditID},
{4, "Uber Lisboa", -850, "Transport", creditID},
{5, "Steam — Elden Ring DLC", -2999, "Games", creditID},
{6, "Pingo Doce", -3120, "Groceries", checkingID},
{7, "Farmácia Saúde", -1540, "Health", checkingID},
{8, "Spotify Premium", -999, "Subscriptions", creditID},
{9, "Netflix", -1599, "Subscriptions", creditID},
{10, "Lidl Supermercado", -5640, "Groceries", checkingID},
{11, "Restaurante O Barrigas", -3280, "Food", creditID},
{12, "Trade Republic — VWCE Buy", -50000, "Investments", investID},
{13, "CP — Lisboa Cascais", -310, "Transport", creditID},
{14, "Decathlon", -4990, "Shopping", creditID},
{15, "Rendimento subarrendamento", 30000, "Income", checkingID},
// ── Month 1 ──────────────────────────────────────────────────────
{30, "Salary — Homelab Corp", 230000, "Income", checkingID},
{31, "Auchan Supermercado", -6780, "Groceries", checkingID},
{32, "EDP Energia", -6200, "Utilities", checkingID},
{33, "Renda apartamento", -80000, "Housing", checkingID},
{34, "McDonald's Marquês", -1190, "Food", creditID},
{35, "Zara — Coleção Verão", -8990, "Clothing", creditID},
{36, "Bolt ride", -620, "Transport", creditID},
{37, "NOS Telemóvel", -1799, "Utilities", checkingID},
{38, "Intermarché", -4420, "Groceries", checkingID},
{39, "Ginásio Holmes Place", -4900, "Health", checkingID},
{40, "Amazon Prime", -799, "Subscriptions", creditID},
{41, "Glovo — Burger King", -1890, "Food", creditID},
{42, "Trade Republic — SXR8 Buy", -30000, "Investments", investID},
{43, "Via Verde portagens", -920, "Transport", checkingID},
{44, "Fnac — Livros", -2350, "Shopping", creditID},
{45, "H&M online", -5490, "Clothing", creditID},
// ── Month 2 ──────────────────────────────────────────────────────
{60, "Salary — Homelab Corp", 230000, "Income", checkingID},
{61, "Continente Supermercado", -5120, "Groceries", checkingID},
{62, "MEO Internet", -3999, "Utilities", checkingID},
{63, "Renda apartamento", -80000, "Housing", checkingID},
{64, "Pastelaria Batalha", -480, "Food", creditID},
{65, "Uber Lisboa", -1240, "Transport", creditID},
{66, "Epic Games — Fortnite", -1999, "Games", creditID},
{67, "Lidl Supermercado", -4890, "Groceries", checkingID},
{68, "Farmácia da Baixa", -2310, "Health", checkingID},
{69, "Spotify Premium", -999, "Subscriptions", creditID},
{70, "Netflix", -1599, "Subscriptions", creditID},
{71, "Restaurante Eleven", -9400, "Food", creditID},
{72, "Trade Republic — VWCE Buy", -50000, "Investments", investID},
{73, "Teatro Nacional", -2500, "Entertainment", creditID},
{74, "IKEA Lisboa", -14900, "Shopping", creditID},
{75, "Pingo Doce", -3670, "Groceries", checkingID},
// ── Month 3 ──────────────────────────────────────────────────────
{90, "Salary — Homelab Corp", 230000, "Income", checkingID},
{91, "Auchan Supermercado", -7230, "Groceries", checkingID},
{92, "EDP Energia", -5800, "Utilities", checkingID},
{93, "Renda apartamento", -80000, "Housing", checkingID},
{94, "KFC Colombo", -1440, "Food", creditID},
{95, "Bolt ride", -740, "Transport", creditID},
{96, "NOS Telemóvel", -1799, "Utilities", checkingID},
{97, "Intermarché", -5560, "Groceries", checkingID},
{98, "Consulta médica particular", -8000, "Health", checkingID},
{99, "Disney+", -799, "Subscriptions", creditID},
{100, "Amazon Prime", -799, "Subscriptions", creditID},
{101, "Restaurante A Cevicheria", -6200, "Food", creditID},
{102, "Trade Republic — SXR8 Buy", -30000, "Investments", investID},
{103, "CP — InterCity Porto", -2450, "Transport", checkingID},
{104, "Livraria Bertrand", -1890, "Shopping", creditID},
// ── Month 4 ──────────────────────────────────────────────────────
{120, "Salary — Homelab Corp", 230000, "Income", checkingID},
{121, "Continente Supermercado", -6010, "Groceries", checkingID},
{122, "MEO Internet", -3999, "Utilities", checkingID},
{123, "Renda apartamento", -80000, "Housing", checkingID},
{124, "Glovo — Pizza Hut", -2340, "Food", creditID},
{125, "Uber Lisboa", -990, "Transport", creditID},
{126, "PlayStation Store", -2999, "Games", creditID},
{127, "Lidl Supermercado", -5210, "Groceries", checkingID},
{128, "Farmácia Saúde", -890, "Health", checkingID},
{129, "Spotify Premium", -999, "Subscriptions", creditID},
{130, "Netflix", -1599, "Subscriptions", creditID},
{131, "Tasca do Chico — jantar", -5400, "Food", creditID},
{132, "Trade Republic — VWCE Buy", -50000, "Investments", investID},
{133, "Fnac — AirPods", -17900, "Shopping", creditID},
{134, "Pingo Doce", -4120, "Groceries", checkingID},
// ── Month 5 ──────────────────────────────────────────────────────
{150, "Salary — Homelab Corp", 230000, "Income", checkingID},
{151, "Auchan Supermercado", -5890, "Groceries", checkingID},
{152, "EDP Energia", -6400, "Utilities", checkingID},
{153, "Renda apartamento", -80000, "Housing", checkingID},
{154, "Nando's Lisboa", -1980, "Food", creditID},
{155, "Bolt ride", -510, "Transport", creditID},
{156, "NOS Telemóvel", -1799, "Utilities", checkingID},
{157, "Intermarché", -4780, "Groceries", checkingID},
{158, "Óculos — Ótica Avenida", -12000, "Health", checkingID},
{159, "Disney+", -799, "Subscriptions", creditID},
{160, "Amazon Prime", -799, "Subscriptions", creditID},
{161, "Cinemateca Portuguesa", -600, "Entertainment", creditID},
{162, "Trade Republic — VWCE Buy", -50000, "Investments", investID},
{163, "Zara — Outono", -12490, "Clothing", creditID},
{164, "Worten — Monitor", -34900, "Shopping", creditID},
}
for _, td := range rawTxns {
date := now.AddDate(0, 0, -td.daysAgo).Truncate(24 * time.Hour)
txns = append(txns, Transaction{
ID: bson.NewObjectID().Hex(),
UserID: userID,
AccountID: td.accountID,
Date: date,
Description: td.desc,
AmountCents: td.amountCents,
Category: td.cat,
CreatedAt: time.Now(),
})
}
if err := store.createTransactions(ctx, txns); err != nil {
return fmt.Errorf("create transactions: %w", err)
}
// ── Portfolio trades ─────────────────────────────────────────────────
trades := []Trade{
// VWCE — Vanguard FTSE All-World
{
ID: bson.NewObjectID().Hex(), UserID: userID,
ISIN: "IE00B3RBWM25", Name: "VWCE - Vanguard All-World",
Type: "buy", Quantity: 12, PriceCents: 11820, TotalCents: 141840,
Date: now.AddDate(0, -5, 5), CreatedAt: time.Now(),
},
{
ID: bson.NewObjectID().Hex(), UserID: userID,
ISIN: "IE00B3RBWM25", Name: "VWCE - Vanguard All-World",
Type: "buy", Quantity: 8, PriceCents: 11960, TotalCents: 95680,
Date: now.AddDate(0, -3, 12), CreatedAt: time.Now(),
},
{
ID: bson.NewObjectID().Hex(), UserID: userID,
ISIN: "IE00B3RBWM25", Name: "VWCE - Vanguard All-World",
Type: "buy", Quantity: 6, PriceCents: 12100, TotalCents: 72600,
Date: now.AddDate(0, -1, 12), CreatedAt: time.Now(),
},
// SXR8 — iShares Core S&P 500
{
ID: bson.NewObjectID().Hex(), UserID: userID,
ISIN: "IE00B5BMR087", Name: "SXR8 - iShares S&P 500",
Type: "buy", Quantity: 15, PriceCents: 53200, TotalCents: 798000,
Date: now.AddDate(0, -4, 8), CreatedAt: time.Now(),
},
{
ID: bson.NewObjectID().Hex(), UserID: userID,
ISIN: "IE00B5BMR087", Name: "SXR8 - iShares S&P 500",
Type: "buy", Quantity: 10, PriceCents: 55100, TotalCents: 551000,
Date: now.AddDate(0, -2, 3), CreatedAt: time.Now(),
},
// EUNL — iShares Core MSCI World
{
ID: bson.NewObjectID().Hex(), UserID: userID,
ISIN: "IE00B4L5Y983", Name: "EUNL - iShares MSCI World",
Type: "buy", Quantity: 20, PriceCents: 8950, TotalCents: 179000,
Date: now.AddDate(0, -5, 20), CreatedAt: time.Now(),
},
{
ID: bson.NewObjectID().Hex(), UserID: userID,
ISIN: "IE00B4L5Y983", Name: "EUNL - iShares MSCI World",
Type: "buy", Quantity: 10, PriceCents: 9210, TotalCents: 92100,
Date: now.AddDate(0, -2, 18), CreatedAt: time.Now(),
},
}
if err := store.createTrades(ctx, trades); err != nil {
return fmt.Errorf("create trades: %w", err)
}
return nil
}