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>
This commit is contained in:
Gonçalo Rodrigues 2026-06-13 12:16:23 +01:00
parent 0a2beb2973
commit 7a2cb10c79
12 changed files with 1439 additions and 758 deletions

View File

@ -32,6 +32,9 @@ func main() {
defer db.Close(ctx) defer db.Close(ctx)
store := NewStore(db) store := NewStore(db)
go SeedAdmin(ctx, store)
handler := NewHandler(store) handler := NewHandler(store)
mux := http.NewServeMux() mux := http.NewServeMux()

View File

@ -0,0 +1,314 @@
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
}

View File

@ -2,13 +2,14 @@
{{$d := .}} {{$d := .}}
<h1 style="margin-bottom:24px;">Accounts</h1> <h1 style="margin-bottom:24px;">Accounts</h1>
<div class="card"> <div class="card animate-on-scroll">
<form method="POST" class="flex flex-wrap" style="gap: 12px; align-items: end;"> <h2 style="margin-bottom:16px;">Add Account</h2>
<div class="form-group" style="margin-bottom: 0; flex: 1;"> <form method="POST" class="flex flex-wrap" style="gap:10px; align-items:flex-end;">
<div class="form-group" style="margin-bottom:0; flex:1; min-width:180px;">
<label>Account Name</label> <label>Account Name</label>
<input type="text" name="name" placeholder="e.g. CGD Current Account" required> <input type="text" name="name" placeholder="e.g. CGD Checking" required>
</div> </div>
<div class="form-group" style="margin-bottom: 0; flex: 1;"> <div class="form-group" style="margin-bottom:0; min-width:160px;">
<label>Type</label> <label>Type</label>
<select name="type"> <select name="type">
<option value="checking">Checking</option> <option value="checking">Checking</option>
@ -17,27 +18,41 @@
<option value="securities">Securities</option> <option value="securities">Securities</option>
</select> </select>
</div> </div>
<button type="submit" class="btn btn-primary">Add Account</button> <button type="submit" class="btn btn-primary">Add</button>
</form> </form>
</div> </div>
<div class="card"> <div class="card animate-on-scroll">
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>Name</th><th>Type</th><th></th></tr> <tr>
<th>Name</th>
<th>Type</th>
<th></th>
</tr>
</thead> </thead>
<tbody> <tbody>
{{range $d.Accounts}} {{range $d.Accounts}}
<tr> <tr id="acct-row-{{.ID}}">
<td>{{.Name}}</td> <td style="font-weight:500;">{{.Name}}</td>
<td><span class="badge">{{.Type}}</span></td>
<td> <td>
{{$icon := "🏦"}}
{{if eq .Type "savings"}}{{$icon = "🏧"}}{{end}}
{{if eq .Type "credit"}}{{$icon = "💳"}}{{end}}
{{if eq .Type "securities"}}{{$icon = "📈"}}{{end}}
<span class="badge" style="background:var(--bg3); color:var(--text2); border:1px solid var(--border2);">
{{$icon}} {{.Type}}
</span>
</td>
<td class="text-right">
<button class="btn btn-danger btn-sm" onclick="delAccount('{{.ID}}')">Delete</button> <button class="btn btn-danger btn-sm" onclick="delAccount('{{.ID}}')">Delete</button>
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="3" class="text-center text-muted">No accounts yet.</td></tr> <tr>
<td colspan="3" class="text-center text-muted" style="padding:36px;">No accounts yet.</td>
</tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
@ -47,7 +62,9 @@
<script> <script>
function delAccount(id) { function delAccount(id) {
if (!confirm('Delete this account?')) return; if (!confirm('Delete this account?')) return;
fetch('/accounts/' + id, {method: 'DELETE'}).then(() => location.reload()); fetch('/accounts/' + id, {method: 'DELETE'}).then(r => {
if (r.ok) document.getElementById('acct-row-' + id).remove();
});
} }
</script> </script>
{{end}} {{end}}

View File

@ -1,122 +1,354 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{if .Title}}{{.Title}} — {{end}}Finance</title> <title>{{if .Title}}{{.Title}} — {{end}}Finance</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style> <style>
* { box-sizing: border-box; margin: 0; padding: 0; } /* ── Tokens ─────────────────────────────────────────────────────── */
:root {
--bg: #0f1117;
--bg2: #161b27;
--bg3: #1e2535;
--surface: rgba(30, 37, 53, 0.85);
--surface2: rgba(40, 50, 72, 0.7);
--border: rgba(255,255,255,0.07);
--border2: rgba(255,255,255,0.12);
--text: #e8eaf6;
--text2: #9fa8c7;
--text3: #5c6585;
--accent: #6979f8;
--accent2: #8b9ffc;
--accent-glow: rgba(105,121,248,0.25);
--green: #4ade80;
--red: #f87171;
--green-dim: rgba(74,222,128,0.15);
--red-dim: rgba(248,113,113,0.15);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
--shadow-md: 0 4px 16px rgba(0,0,0,0.5), 0 2px 6px rgba(0,0,0,0.3);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.6), 0 4px 12px rgba(0,0,0,0.4);
--radius: 14px;
--radius-sm: 8px;
--nav-h: 58px;
}
[data-theme="light"] {
--bg: #f0f2f8;
--bg2: #e4e8f4;
--bg3: #d8ddf0;
--surface: rgba(255,255,255,0.9);
--surface2: rgba(240,242,248,0.8);
--border: rgba(0,0,0,0.07);
--border2: rgba(0,0,0,0.12);
--text: #1a1f36;
--text2: #4a5275;
--text3: #8a92b0;
--accent: #4355e8;
--accent2: #6373f0;
--accent-glow: rgba(67,85,232,0.18);
--green: #16a34a;
--red: #dc2626;
--green-dim: rgba(22,163,74,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-md: 0 4px 16px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.12), 0 4px 12px rgba(0,0,0,0.07);
}
/* ── Reset & base ────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%); background: var(--bg);
color: #333; min-height: 100vh; color: var(--text);
animation: fadeIn 0.5s ease-out; min-height: 100vh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
transition: background 0.3s ease, color 0.3s ease;
} }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); } /* Subtle grid texture overlay */
to { opacity: 1; transform: translateY(0); } body::before {
} content: '';
@keyframes slideInRow { position: fixed;
from { opacity: 0; transform: translateX(-12px); } inset: 0;
to { opacity: 1; transform: translateX(0); } background-image:
} radial-gradient(ellipse 80% 60% at 20% 10%, rgba(105,121,248,0.08) 0%, transparent 60%),
@keyframes pulse { radial-gradient(ellipse 60% 50% at 80% 80%, rgba(139,159,252,0.05) 0%, transparent 55%);
0%, 100% { transform: scale(1); } pointer-events: none;
50% { transform: scale(1.05); } z-index: 0;
}
@keyframes shimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
} }
body > * { position: relative; z-index: 1; }
/* ── Animations ──────────────────────────────────────────────────── */
@keyframes fadeUp { from { opacity:0; transform:translateY(12px); } to { opacity:1; transform:translateY(0); } }
@keyframes slideIn { from { opacity:0; transform:translateX(-10px); } to { opacity:1; transform:translateX(0); } }
@keyframes shimmer { 0% { background-position:-200% center; } 100% { background-position:200% center; } }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.6; } }
@keyframes spin { to { transform:rotate(360deg); } }
/* ── Nav ─────────────────────────────────────────────────────────── */
.nav { .nav {
background: linear-gradient(135deg, #1a237e, #283593); height: var(--nav-h);
color: #fff; padding: 0 24px; background: rgba(15,17,23,0.85);
display: flex; align-items: center; height: 56px; gap: 24px; backdrop-filter: blur(16px) saturate(180%);
box-shadow: 0 2px 8px rgba(0,0,0,0.15); -webkit-backdrop-filter: blur(16px) saturate(180%);
position: sticky; top: 0; z-index: 100; border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 28px;
gap: 4px;
position: sticky;
top: 0;
z-index: 200;
box-shadow: 0 1px 0 var(--border), 0 4px 20px rgba(0,0,0,0.3);
} }
.nav a { [data-theme="light"] .nav {
color: #c5cae9; text-decoration: none; font-size: 14px; font-weight: 500; background: rgba(240,242,248,0.88);
position: relative; transition: color 0.2s ease; box-shadow: 0 1px 0 var(--border), 0 4px 20px rgba(0,0,0,0.08);
} }
.nav a::after { .nav-brand {
content: ''; position: absolute; bottom: -4px; left: 0; right: 0; font-size: 17px;
height: 2px; background: #fff; border-radius: 1px; font-weight: 700;
transform: scaleX(0); transition: transform 0.25s ease; color: var(--text);
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
margin-right: 16px;
letter-spacing: -0.3px;
} }
.nav a:hover { color: #fff; } .nav-brand-icon {
.nav a:hover::after { transform: scaleX(1); } width: 28px; height: 28px;
.nav a.active { color: #fff; } background: linear-gradient(135deg, var(--accent), var(--accent2));
.nav a.active::after { transform: scaleX(1); } border-radius: 7px;
.nav .brand { font-size: 18px; font-weight: 700; color: #fff; margin-right: auto; } display: flex; align-items: center; justify-content: center;
.nav .brand::after { display: none; } font-size: 14px;
.nav .email { font-size: 12px; color: #9fa8da; } box-shadow: 0 2px 8px var(--accent-glow);
.container { max-width: 1200px; margin: 0 auto; padding: 24px; } }
.nav a:not(.nav-brand) {
color: var(--text2);
text-decoration: none;
font-size: 13.5px;
font-weight: 500;
padding: 6px 10px;
border-radius: var(--radius-sm);
transition: all 0.18s ease;
white-space: nowrap;
}
.nav a:not(.nav-brand):hover { color: var(--text); background: var(--surface2); }
.nav a.active { color: var(--accent2); background: var(--accent-glow); }
.nav-spacer { flex: 1; }
.nav-email {
font-size: 12px;
color: var(--text3);
padding: 0 8px;
}
.theme-btn {
width: 34px; height: 34px;
border-radius: 9px;
border: 1px solid var(--border2);
background: var(--surface2);
color: var(--text2);
cursor: pointer;
font-size: 15px;
display: flex; align-items: center; justify-content: center;
transition: all 0.18s ease;
}
.theme-btn:hover { color: var(--text); background: var(--bg3); transform: scale(1.05); }
/* ── Layout ──────────────────────────────────────────────────────── */
.container {
max-width: 1240px;
margin: 0 auto;
padding: 28px 24px 48px;
animation: fadeUp 0.4s ease-out both;
}
h1 {
font-size: 22px;
font-weight: 700;
color: var(--text);
letter-spacing: -0.4px;
}
/* ── Cards ───────────────────────────────────────────────────────── */
.card { .card {
background: rgba(255,255,255,0.95); backdrop-filter: blur(8px); background: var(--surface);
border-radius: 12px; padding: 24px; margin-bottom: 16px; border: 1px solid var(--border);
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.06); border-radius: var(--radius);
transition: transform 0.2s ease, box-shadow 0.2s ease; padding: 22px;
animation: fadeIn 0.4s ease-out both; margin-bottom: 16px;
box-shadow: var(--shadow-sm);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
} }
.card:hover { .card:hover {
transform: translateY(-2px); box-shadow: var(--shadow-md);
box-shadow: 0 8px 25px rgba(0,0,0,0.1), 0 2px 8px rgba(0,0,0,0.06); border-color: var(--border2);
} }
.card h2 { font-size: 16px; color: #666; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; } .card h2 {
.card .value { font-size: 28px; font-weight: 700; } font-size: 11px;
.card .value.positive { color: #4caf50; } font-weight: 600;
.card .value.negative { color: #f44336; } color: var(--text3);
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; } text-transform: uppercase;
.table-wrap { overflow-x: auto; border-radius: 8px; } letter-spacing: 0.8px;
table { width: 100%; border-collapse: collapse; font-size: 14px; } margin-bottom: 10px;
}
.card .value {
font-size: 30px;
font-weight: 700;
letter-spacing: -0.8px;
line-height: 1.1;
}
/* value cards with colored top accent */
.value-card { position: relative; overflow: hidden; }
.value-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: var(--radius);
background: linear-gradient(135deg, var(--accent-glow), transparent 60%);
pointer-events: none;
}
.value-card::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent2), transparent);
border-radius: var(--radius) var(--radius) 0 0;
background-size: 200% auto;
animation: shimmer 3s ease-in-out infinite;
}
/* ── Colour utilities ────────────────────────────────────────────── */
.positive { color: var(--green); }
.negative { color: var(--red); }
/* ── Grid ────────────────────────────────────────────────────────── */
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 16px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px; }
/* ── Tables ──────────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; border-radius: var(--radius-sm); }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
th { th {
text-align: left; padding: 10px 12px; border-bottom: 2px solid #e0e0e0; text-align: left;
color: #666; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; padding: 9px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.6px;
border-bottom: 1px solid var(--border2);
white-space: nowrap;
} }
td { padding: 10px 12px; border-bottom: 1px solid #f0f0f0; } td {
tbody tr { padding: 10px 12px;
animation: slideInRow 0.3s ease-out both; border-bottom: 1px solid var(--border);
} color: var(--text);
tbody tr:nth-child(1) { animation-delay: 0.02s; } vertical-align: middle;
tbody tr:nth-child(2) { animation-delay: 0.04s; }
tbody tr:nth-child(3) { animation-delay: 0.06s; }
tbody tr:nth-child(4) { animation-delay: 0.08s; }
tbody tr:nth-child(5) { animation-delay: 0.10s; }
tbody tr:nth-child(6) { animation-delay: 0.12s; }
tbody tr:nth-child(7) { animation-delay: 0.14s; }
tbody tr:nth-child(8) { animation-delay: 0.16s; }
tbody tr:nth-child(9) { animation-delay: 0.18s; }
tbody tr:nth-child(10) { animation-delay: 0.20s; }
tbody tr:nth-child(11) { animation-delay: 0.22s; }
tbody tr:nth-child(12) { animation-delay: 0.24s; }
tr:hover td { background: #f5f7ff; }
.badge {
display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;
} }
tbody tr { transition: background 0.12s ease; }
tbody tr:hover td { background: var(--surface2); }
tbody tr:last-child td { border-bottom: none; }
.cents { text-align: right; font-variant-numeric: tabular-nums; }
/* ── Buttons ─────────────────────────────────────────────────────── */
.btn { .btn {
display: inline-block; padding: 8px 16px; border-radius: 8px; font-size: 14px; font-weight: 500; display: inline-flex; align-items: center; gap: 5px;
border: none; cursor: pointer; text-decoration: none; padding: 8px 16px;
transition: all 0.2s ease; border-radius: var(--radius-sm);
font-size: 13.5px;
font-weight: 500;
border: none;
cursor: pointer;
text-decoration: none;
transition: all 0.18s ease;
white-space: nowrap;
line-height: 1;
} }
.btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); } .btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); } .btn:active { transform: translateY(0); }
.btn-primary { background: linear-gradient(135deg, #3949ab, #5c6bc0); color: #fff; } .btn-primary {
.btn-primary:hover { background: linear-gradient(135deg, #303f9f, #3949ab); } background: linear-gradient(135deg, var(--accent) 0%, var(--accent2) 100%);
.btn-danger { background: linear-gradient(135deg, #e53935, #ef5350); color: #fff; } color: #fff;
.btn-outline { background: transparent; border: 1px solid #c5cae9; color: #3949ab; } box-shadow: 0 2px 10px var(--accent-glow);
.btn-outline:hover { background: #f5f7ff; border-color: #3949ab; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; color: #555; }
.form-group input, .form-group select {
width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
} }
.form-group input:focus, .form-group select:focus { .btn-primary:hover { box-shadow: 0 4px 18px var(--accent-glow); }
outline: none; border-color: #3949ab; box-shadow: 0 0 0 3px rgba(57,73,171,0.1); .btn-danger {
background: var(--red-dim);
color: var(--red);
border: 1px solid rgba(248,113,113,0.2);
} }
.btn-danger:hover { background: rgba(248,113,113,0.25); }
.btn-outline {
background: transparent;
border: 1px solid var(--border2);
color: var(--text2);
}
.btn-outline:hover { background: var(--surface2); color: var(--text); border-color: var(--accent); }
.btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
/* ── Forms ───────────────────────────────────────────────────────── */
.form-group { margin-bottom: 14px; }
.form-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text2);
margin-bottom: 5px;
letter-spacing: 0.3px;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border2);
border-radius: var(--radius-sm);
font-size: 13.5px;
background: var(--bg2);
color: var(--text);
transition: border-color 0.18s ease, box-shadow 0.18s ease;
outline: none;
}
.form-group input:focus,
.form-group select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input[type="color"] { padding: 4px; height: 38px; cursor: pointer; }
select option { background: var(--bg2); color: var(--text); }
/* ── Badges ──────────────────────────────────────────────────────── */
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 10px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.2px;
}
.category-dot {
width: 7px; height: 7px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
/* ── Empty states ────────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 52px 24px;
color: var(--text3);
}
.empty-state h3 { font-size: 17px; color: var(--text2); margin-bottom: 8px; }
/* ── Misc utils ──────────────────────────────────────────────────── */
.flex { display: flex; gap: 8px; align-items: center; } .flex { display: flex; gap: 8px; align-items: center; }
.flex-wrap { flex-wrap: wrap; } .flex-wrap { flex-wrap: wrap; }
.mb-16 { margin-bottom: 16px; } .mb-16 { margin-bottom: 16px; }
@ -124,39 +356,41 @@
.mt-16 { margin-top: 16px; } .mt-16 { margin-top: 16px; }
.text-center { text-align: center; } .text-center { text-align: center; }
.text-right { text-align: right; } .text-right { text-align: right; }
.text-muted { color: #999; font-size: 12px; } .text-muted { color: var(--text3); font-size: 12px; }
.error { color: #f44336; font-size: 14px; margin-bottom: 16px; } .error { color: var(--red); font-size: 13px; margin-bottom: 12px; }
.success { color: #4caf50; font-size: 14px; margin-bottom: 16px; } .success { color: var(--green); font-size: 13px; margin-bottom: 12px; }
.empty-state { text-align: center; padding: 48px; color: #999; }
.empty-state h3 { font-size: 18px; margin-bottom: 8px; color: #666; } /* ── Scroll-reveal ───────────────────────────────────────────────── */
table .cents { text-align: right; font-variant-numeric: tabular-nums; }
.category-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 12px; color: #fff; }
.value-card { position: relative; overflow: hidden; }
.value-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, #3949ab, #5c6bc0, #7986cb);
background-size: 200% auto; animation: shimmer 3s ease-in-out infinite;
}
.chart-container { position: relative; width: 100%; }
.animate-on-scroll { .animate-on-scroll {
opacity: 0; transform: translateY(24px); transition: opacity 0.6s ease-out, transform 0.6s ease-out; opacity: 0; transform: translateY(18px);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
} }
.animate-on-scroll.visible { opacity: 1; transform: translateY(0); } .animate-on-scroll.visible { opacity: 1; transform: translateY(0); }
.animate-on-scroll:nth-child(2) { transition-delay: 0.1s; } .animate-on-scroll:nth-child(2) { transition-delay: 0.07s; }
.animate-on-scroll:nth-child(3) { transition-delay: 0.2s; } .animate-on-scroll:nth-child(3) { transition-delay: 0.14s; }
.animate-on-scroll:nth-child(4) { transition-delay: 0.3s; } .animate-on-scroll:nth-child(4) { transition-delay: 0.21s; }
.animate-on-scroll:nth-child(5) { transition-delay: 0.4s; } .animate-on-scroll:nth-child(5) { transition-delay: 0.28s; }
@media (max-width: 768px) {
.nav { padding: 0 12px; gap: 12px; overflow-x: auto; } /* ── Responsive ──────────────────────────────────────────────────── */
.container { padding: 12px; } @media (max-width: 900px) {
.grid { grid-template-columns: 1fr; } .grid-2 { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.nav { padding: 0 12px; gap: 2px; overflow-x: auto; }
.nav a:not(.nav-brand) { padding: 6px 7px; font-size: 12px; }
.container { padding: 16px 12px 32px; }
.grid { grid-template-columns: 1fr 1fr; }
.card { padding: 16px; }
.nav-email { display: none; }
} }
</style> </style>
</head> </head>
<body> <body>
<nav class="nav"> <nav class="nav">
<a href="/" class="brand">Finance</a> <a href="/" class="nav-brand">
<div class="nav-brand-icon"></div>
Finance
</a>
<a href="/" class="{{if eq .Route "dashboard"}}active{{end}}">Dashboard</a> <a href="/" class="{{if eq .Route "dashboard"}}active{{end}}">Dashboard</a>
<a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">Transactions</a> <a href="/transactions" class="{{if eq .Route "transactions"}}active{{end}}">Transactions</a>
<a href="/import" class="{{if eq .Route "import"}}active{{end}}">Import</a> <a href="/import" class="{{if eq .Route "import"}}active{{end}}">Import</a>
@ -166,49 +400,75 @@
<a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">Projections</a> <a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">Projections</a>
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a> <a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
<a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a> <a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a>
<span class="email">{{.Email}}</span> <div class="nav-spacer"></div>
<span class="nav-email">{{.Email}}</span>
<button class="theme-btn" id="theme-toggle" title="Toggle dark/light mode">🌙</button>
</nav> </nav>
<div class="container"> <div class="container">
{{block "content" .}}{{end}} {{block "content" .}}{{end}}
</div> </div>
<script> <script>
/* ── Theme toggle ─────────────────────────────────────────────── */
const html = document.documentElement;
const btn = document.getElementById('theme-toggle');
function applyTheme(t) {
html.setAttribute('data-theme', t);
btn.textContent = t === 'dark' ? '☀️' : '🌙';
localStorage.setItem('theme', t);
}
const saved = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
applyTheme(saved);
btn.addEventListener('click', () =>
applyTheme(html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'));
/* ── Animated counter ─────────────────────────────────────────── */
function animateCounter(el) { function animateCounter(el) {
const target = parseFloat(el.getAttribute('data-target')); const target = parseFloat(el.getAttribute('data-target'));
const suffix = el.getAttribute('data-suffix') || '';
const prefix = el.getAttribute('data-prefix') || ''; const prefix = el.getAttribute('data-prefix') || '';
const duration = parseInt(el.getAttribute('data-duration')) || 800; const duration = parseInt(el.getAttribute('data-duration')) || 900;
const isPct = el.getAttribute('data-pct') !== null;
const start = performance.now(); const start = performance.now();
function step(now) {
function update(now) {
const t = Math.min((now - start) / duration, 1); const t = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - t, 3); const e = 1 - Math.pow(1 - t, 3);
const current = target * eased; const v = target * e;
if (isPct) { const abs = Math.abs(v);
el.textContent = prefix + (current >= 0 ? '+' : '') + current.toFixed(2) + '%' + suffix; const sign = v < 0 ? '' : (target > 0 ? '+' : '');
} else { el.textContent = prefix + sign + (abs / 100).toLocaleString('pt-PT', {minimumFractionDigits:2, maximumFractionDigits:2});
el.textContent = prefix + Math.round(current).toLocaleString() + suffix; if (t < 1) requestAnimationFrame(step);
} }
if (t < 1) requestAnimationFrame(update); requestAnimationFrame(step);
}
requestAnimationFrame(update);
} }
const observer = new IntersectionObserver((entries) => { /* ── Scroll reveal + counter trigger ─────────────────────────── */
const io = new IntersectionObserver((entries) => {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting) { if (!entry.isIntersecting) return;
entry.target.classList.add('visible'); entry.target.classList.add('visible');
const counter = entry.target.querySelector('.animate-counter'); entry.target.querySelectorAll('.animate-counter').forEach(c => {
if (counter && !counter.dataset.counted) { if (!c.dataset.counted) { c.dataset.counted = '1'; animateCounter(c); }
counter.dataset.counted = 'true';
animateCounter(counter);
}
observer.unobserve(entry.target);
}
}); });
}, { threshold: 0.1 }); io.unobserve(entry.target);
});
}, { threshold: 0.08 });
document.querySelectorAll('.animate-on-scroll').forEach(el => observer.observe(el)); document.querySelectorAll('.animate-on-scroll').forEach(el => io.observe(el));
/* ── Chart.js defaults for dark/light ─────────────────────────── */
function getThemeColor(v) {
const dark = html.getAttribute('data-theme') === 'dark';
return {
gridColor: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)',
textColor: dark ? '#5c6585' : '#9fa8c7',
}[v];
}
Chart.defaults.color = () => getThemeColor('textColor');
Chart.defaults.borderColor = () => getThemeColor('gridColor');
</script> </script>
</body> </body>
</html> </html>

View File

@ -4,14 +4,14 @@
<div class="card animate-on-scroll"> <div class="card animate-on-scroll">
<h2 style="margin-bottom:16px;">Add Category</h2> <h2 style="margin-bottom:16px;">Add Category</h2>
<form method="POST" class="flex flex-wrap" style="gap:12px; align-items:flex-end;"> <form method="POST" class="flex flex-wrap" style="gap:10px; align-items:flex-end;">
<div class="form-group" style="margin-bottom:0; flex:1; min-width:160px;"> <div class="form-group" style="margin-bottom:0; flex:1; min-width:160px;">
<label>Name</label> <label>Name</label>
<input type="text" name="name" placeholder="e.g. Dining" required> <input type="text" name="name" placeholder="e.g. Dining" required>
</div> </div>
<div class="form-group" style="margin-bottom:0; width:90px;"> <div class="form-group" style="margin-bottom:0; width:90px;">
<label>Color</label> <label>Color</label>
<input type="color" name="color" value="#7986CB" style="padding:4px; height:38px;"> <input type="color" name="color" value="#7986CB">
</div> </div>
<button type="submit" class="btn btn-primary">Add</button> <button type="submit" class="btn btn-primary">Add</button>
</form> </form>
@ -22,7 +22,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="width:40px;">Color</th> <th style="width:44px;"></th>
<th>Name</th> <th>Name</th>
<th>Monthly Budget</th> <th>Monthly Budget</th>
<th></th> <th></th>
@ -32,28 +32,35 @@
{{range $d.Categories}} {{range $d.Categories}}
<tr id="cat-row-{{.ID}}"> <tr id="cat-row-{{.ID}}">
<td> <td>
<span style="display:inline-block; width:22px; height:22px; border-radius:50%; background:{{.Color}}; border:2px solid {{.Color}}44; vertical-align:middle;"></span> <span style="display:inline-block; width:24px; height:24px; border-radius:50%;
background:{{.Color}}; box-shadow:0 0 10px {{.Color}}66;"></span>
</td> </td>
<td style="font-weight:500;">{{.Name}}</td> <td style="font-weight:500;">{{.Name}}</td>
<td> <td>
<span id="budget-display-{{.ID}}" style="font-size:14px; color:#444;"> <span id="budget-display-{{.ID}}" style="font-size:13.5px; color:var(--text2);">
{{if gt .BudgetCents 0}}€{{cents .BudgetCents}}{{else}}<span class="text-muted">No budget set</span>{{end}} {{if gt .BudgetCents 0}}€{{cents .BudgetCents}}{{else}}<span class="text-muted">No budget</span>{{end}}
</span> </span>
<span id="budget-edit-{{.ID}}" style="display:none;"> <span id="budget-edit-{{.ID}}" style="display:none; align-items:center; gap:6px;">
<input type="number" id="budget-input-{{.ID}}" placeholder="0.00" step="0.01" min="0" <input type="number" id="budget-input-{{.ID}}" placeholder="0.00" step="0.01" min="0"
value="{{if gt .BudgetCents 0}}{{div .BudgetCents 100}}{{end}}" value="{{if gt .BudgetCents 0}}{{div .BudgetCents 100}}{{end}}"
style="width:100px; padding:4px 8px; border:1px solid #c5cae9; border-radius:6px; font-size:13px;"> style="width:110px; padding:5px 9px; border:1px solid var(--border2);
border-radius:6px; font-size:13px; background:var(--bg2); color:var(--text);">
<button class="btn btn-primary btn-sm" onclick="saveBudget('{{.ID}}')">Save</button> <button class="btn btn-primary btn-sm" onclick="saveBudget('{{.ID}}')">Save</button>
<button class="btn btn-outline btn-sm" onclick="cancelBudget('{{.ID}}')">Cancel</button> <button class="btn btn-outline btn-sm" onclick="cancelBudget('{{.ID}}')">Cancel</button>
</span> </span>
<button class="btn btn-outline btn-sm" id="budget-btn-{{.ID}}" onclick="editBudget('{{.ID}}')" style="margin-left:8px;">Edit</button> <button class="btn btn-outline btn-sm" id="budget-btn-{{.ID}}"
onclick="editBudget('{{.ID}}')" style="margin-left:8px;">Edit</button>
</td> </td>
<td> <td>
<button class="btn btn-danger btn-sm" onclick="delCat('{{.ID}}')">Delete</button> <button class="btn btn-danger btn-sm" onclick="delCat('{{.ID}}')">Delete</button>
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="4" class="text-center text-muted" style="padding:32px;">No categories yet. Add one above.</td></tr> <tr>
<td colspan="4" class="text-center text-muted" style="padding:36px;">
No categories yet. Add one above.
</td>
</tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
@ -64,18 +71,15 @@
function editBudget(id) { function editBudget(id) {
document.getElementById('budget-display-' + id).style.display = 'none'; document.getElementById('budget-display-' + id).style.display = 'none';
document.getElementById('budget-btn-' + id).style.display = 'none'; document.getElementById('budget-btn-' + id).style.display = 'none';
document.getElementById('budget-edit-' + id).style.display = 'inline-flex'; const edit = document.getElementById('budget-edit-' + id);
document.getElementById('budget-edit-' + id).style.gap = '6px'; edit.style.display = 'inline-flex';
document.getElementById('budget-edit-' + id).style.alignItems = 'center';
document.getElementById('budget-input-' + id).focus(); document.getElementById('budget-input-' + id).focus();
} }
function cancelBudget(id) { function cancelBudget(id) {
document.getElementById('budget-display-' + id).style.display = 'inline'; document.getElementById('budget-display-' + id).style.display = 'inline';
document.getElementById('budget-btn-' + id).style.display = 'inline-block'; document.getElementById('budget-btn-' + id).style.display = 'inline-flex';
document.getElementById('budget-edit-' + id).style.display = 'none'; document.getElementById('budget-edit-' + id).style.display = 'none';
} }
function saveBudget(id) { function saveBudget(id) {
const val = parseFloat(document.getElementById('budget-input-' + id).value.replace(',', '.')); const val = parseFloat(document.getElementById('budget-input-' + id).value.replace(',', '.'));
if (isNaN(val) || val < 0) return; if (isNaN(val) || val < 0) return;
@ -86,14 +90,15 @@ function saveBudget(id) {
body: JSON.stringify({budget_cents: cents}) body: JSON.stringify({budget_cents: cents})
}).then(r => { }).then(r => {
if (!r.ok) return; if (!r.ok) return;
const display = document.getElementById('budget-display-' + id); const d = document.getElementById('budget-display-' + id);
display.innerHTML = cents > 0 ? '€' + (cents / 100).toFixed(2) : '<span class="text-muted">No budget set</span>'; d.innerHTML = cents > 0
? '€' + (cents / 100).toLocaleString('pt-PT', {minimumFractionDigits:2})
: '<span class="text-muted">No budget</span>';
cancelBudget(id); cancelBudget(id);
}); });
} }
function delCat(id) { function delCat(id) {
if (!confirm('Delete this category? Existing transactions will keep their category label.')) return; if (!confirm('Delete this category? Transactions keep their label.')) return;
fetch('/categories/' + id, {method: 'DELETE'}).then(r => { fetch('/categories/' + id, {method: 'DELETE'}).then(r => {
if (r.ok) document.getElementById('cat-row-' + id).remove(); if (r.ok) document.getElementById('cat-row-' + id).remove();
}); });

View File

@ -1,48 +1,56 @@
{{define "content"}} {{define "content"}}
{{$d := .}} {{$d := .}}
{{$change := sub $d.ThisMonth.TotalCents $d.LastMonth.TotalCents}} {{$change := sub $d.ThisMonth.TotalCents $d.LastMonth.TotalCents}}
<h1 style="margin-bottom:24px;">Dashboard</h1>
<!-- Summary cards --> <div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));"> <h1>Dashboard</h1>
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
</div>
<!-- KPI cards -->
<div class="grid">
<div class="card value-card animate-on-scroll"> <div class="card value-card animate-on-scroll">
<h2>This Month (Net)</h2> <h2>Net This Month</h2>
<div class="value {{if lt $d.ThisMonth.TotalCents 0}}negative{{else}}positive{{end}} animate-counter" <div class="value {{if lt $d.ThisMonth.TotalCents 0}}negative{{else}}positive{{end}} animate-counter"
data-target="{{$d.ThisMonth.TotalCents}}" data-prefix="€">€0</div> data-target="{{$d.ThisMonth.TotalCents}}" data-prefix="€">€0.00</div>
</div> </div>
<div class="card value-card animate-on-scroll"> <div class="card value-card animate-on-scroll">
<h2>Income</h2> <h2>Income</h2>
<div class="value positive animate-counter" <div class="value positive animate-counter"
data-target="{{$d.ThisMonthIncome}}" data-prefix="€">€0</div> data-target="{{$d.ThisMonthIncome}}" data-prefix="€">€0.00</div>
</div> </div>
<div class="card value-card animate-on-scroll"> <div class="card value-card animate-on-scroll">
<h2>Expenses</h2> <h2>Expenses</h2>
<div class="value negative animate-counter" <div class="value negative animate-counter"
data-target="{{$d.ThisMonthExpense}}" data-prefix="€">€0</div> data-target="{{$d.ThisMonthExpense}}" data-prefix="€">€0.00</div>
</div> </div>
<div class="card value-card animate-on-scroll"> <div class="card value-card animate-on-scroll">
<h2>vs Last Month</h2> <h2>vs Last Month</h2>
<div class="value {{if lt $change 0}}negative{{else}}positive{{end}} animate-counter" <div class="value {{if lt $change 0}}negative{{else}}positive{{end}} animate-counter"
data-target="{{$change}}" data-prefix="€">€0</div> data-target="{{$change}}" data-prefix="€">€0.00</div>
</div> </div>
</div> </div>
<!-- Charts row --> <!-- Charts -->
<div class="grid" style="grid-template-columns:1fr 1fr;"> <div class="grid-2">
<div class="card animate-on-scroll"> <div class="card animate-on-scroll">
<h2>Spending by Category (This Month)</h2> <h2>Spending by Category — This Month</h2>
{{if $d.ThisMonth.ByCategory}} {{if $d.ThisMonth.ByCategory}}
<div style="position:relative; padding-top:8px;">
<canvas id="thisMonthChart" height="220"></canvas> <canvas id="thisMonthChart" height="220"></canvas>
</div>
{{else}} {{else}}
<div class="empty-state" style="padding:32px;">No spending data this month.</div> <div class="empty-state" style="padding:32px;">No spending data this month.</div>
{{end}} {{end}}
</div> </div>
<div class="card animate-on-scroll"> <div class="card animate-on-scroll">
<h2>Balance Trend (90 days)</h2> <h2>Balance Trend — 90 Days</h2>
{{if $d.BalanceTrend}} {{if $d.BalanceTrend}}
<div style="position:relative; padding-top:8px;">
<canvas id="balanceChart" height="220"></canvas> <canvas id="balanceChart" height="220"></canvas>
</div>
{{else}} {{else}}
<div class="empty-state" style="padding:32px;">No transactions yet. <a href="/import">Import some!</a></div> <div class="empty-state" style="padding:32px;">No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a></div>
{{end}} {{end}}
</div> </div>
</div> </div>
@ -50,36 +58,42 @@
<!-- Budget progress --> <!-- Budget progress -->
{{if $d.CategoryBudgets}} {{if $d.CategoryBudgets}}
<div class="card animate-on-scroll"> <div class="card animate-on-scroll">
<h2 style="margin-bottom:16px;">Budget vs Actual (This Month)</h2> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:18px;">
<div style="display:flex; flex-direction:column; gap:14px;"> <h2>Budget vs Actual — This Month</h2>
<a href="/categories" class="btn btn-outline btn-sm">Manage budgets</a>
</div>
<div style="display:flex; flex-direction:column; gap:16px;">
{{range $cat, $budget := $d.CategoryBudgets}} {{range $cat, $budget := $d.CategoryBudgets}}
{{$spent := index $d.ThisMonth.ByCategory $cat}} {{$spent := index $d.ThisMonth.ByCategory $cat}}
{{$color := index $d.CategoryColors $cat}}
{{$spentAbs := centsAbs $spent}} {{$spentAbs := centsAbs $spent}}
{{$color := index $d.CategoryColors $cat}}
{{$over := isOver $spentAbs $budget}}
{{$pct := clampPct $spentAbs $budget}}
<div> <div>
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:7px;">
<span style="font-size:14px; font-weight:500; display:flex; align-items:center; gap:6px;"> <span style="font-size:13.5px; font-weight:500; display:flex; align-items:center; gap:7px;">
{{if $color}}<span style="width:9px;height:9px;border-radius:50%;background:{{$color}};display:inline-block;"></span>{{end}} {{if $color}}<span style="width:9px;height:9px;border-radius:50%;background:{{$color}};display:inline-block;box-shadow:0 0 6px {{$color}}66;"></span>{{end}}
{{$cat}} {{$cat}}
</span> </span>
<span style="font-size:13px; color:#666;"> <span style="font-size:12.5px; {{if $over}}color:var(--red);font-weight:600;{{else}}color:var(--text2);{{end}}">
€{{cents $spentAbs}} <span style="color:#aaa;">/ €{{cents $budget}}</span> €{{cents $spentAbs}} / €{{cents $budget}}
{{if $over}}&nbsp;⚠ over budget{{end}}
</span> </span>
</div> </div>
<div style="background:#f0f0f0; border-radius:6px; height:8px; overflow:hidden;"> <div style="background:var(--bg3); border-radius:8px; height:7px; overflow:hidden;">
<div style="height:100%; border-radius:6px; width:{{clampPct $spentAbs $budget}}%; transition:width 0.8s ease; <div style="height:100%; border-radius:8px; width:{{$pct}}%; transition:width 1s ease;
background:{{if isOver $spentAbs $budget}}#f44336{{else if $color}}{{$color}}{{else}}#3949ab{{end}};"></div> background:{{if $over}}var(--red){{else if $color}}{{$color}}{{else}}var(--accent){{end}};
box-shadow:{{if $over}}0 0 8px var(--red){{else if $color}}0 0 6px {{$color}}88{{else}}0 0 6px var(--accent-glow){{end}};"></div>
</div> </div>
</div> </div>
{{end}} {{end}}
</div> </div>
<p class="text-muted" style="margin-top:12px;">Set budgets in <a href="/categories">Categories</a>.</p>
</div> </div>
{{end}} {{end}}
<!-- Recent transactions --> <!-- Recent transactions -->
<div class="card animate-on-scroll"> <div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:14px;">
<h2>Recent Transactions</h2> <h2>Recent Transactions</h2>
<a href="/transactions" class="btn btn-outline btn-sm">View all</a> <a href="/transactions" class="btn btn-outline btn-sm">View all</a>
</div> </div>
@ -97,23 +111,27 @@
{{range $d.RecentTxns}} {{range $d.RecentTxns}}
{{$color := index $d.CategoryColors .Category}} {{$color := index $d.CategoryColors .Category}}
<tr> <tr>
<td style="white-space:nowrap;">{{dateShort .Date}}</td> <td style="white-space:nowrap; color:var(--text2);">{{dateShort .Date}}</td>
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td> <td style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
<td> <td>
<span style="display:inline-flex; align-items:center; gap:5px; padding:2px 10px; border-radius:12px; font-size:12px; font-weight:500; <span class="badge" style="
background:{{if $color}}{{$color}}22{{else}}#e0e0e0{{end}}; background:{{if $color}}{{$color}}18{{else}}var(--bg3){{end}};
color:{{if $color}}{{$color}}{{else}}#555{{end}}; color:{{if $color}}{{$color}}{{else}}var(--text2){{end}};
border:1px solid {{if $color}}{{$color}}44{{else}}#ccc{{end}};"> border:1px solid {{if $color}}{{$color}}33{{else}}var(--border2){{end}};">
{{if $color}}<span style="width:7px;height:7px;border-radius:50%;background:{{$color}};"></span>{{end}} {{if $color}}<span class="category-dot" style="background:{{$color}};box-shadow:0 0 4px {{$color}}88;"></span>{{end}}
{{.Category}} {{.Category}}
</span> </span>
</td> </td>
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="white-space:nowrap;"> <td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="font-weight:600; white-space:nowrap;">
{{if lt .AmountCents 0}}{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}} {{if lt .AmountCents 0}}{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="4" class="text-center text-muted" style="padding:32px;">No transactions yet. <a href="/import">Import some!</a></td></tr> <tr>
<td colspan="4" class="text-center text-muted" style="padding:36px;">
No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a>
</td>
</tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
@ -121,69 +139,75 @@
</div> </div>
<script> <script>
const barColors = ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#00E676','#651FFF','#FF6F00','#E91E63','#607D8B','#3F51B5','#9E9E9E'];
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} }; const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
const isDark = () => document.documentElement.getAttribute('data-theme') === 'dark';
function gridColor() { return isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'; }
function textColor() { return isDark() ? '#5c6585' : '#8a92b0'; }
{{if $d.ThisMonth.ByCategory}} {{if $d.ThisMonth.ByCategory}}
const thisMonthLabels = {{jsonKeys $d.ThisMonth.ByCategory}}; const catLabels = {{jsonKeys $d.ThisMonth.ByCategory}};
const thisMonthData = {{jsonVals $d.ThisMonth.ByCategory}}; const catData = {{jsonVals $d.ThisMonth.ByCategory}};
const resolvedColors = thisMonthLabels.map((k,i) => catColors[k] || barColors[i % barColors.length]); const resolvedColors = catLabels.map(k => catColors[k] || '#6979f8');
new Chart(document.getElementById('thisMonthChart'), { new Chart(document.getElementById('thisMonthChart'), {
type: 'bar', type: 'bar',
data: { data: {
labels: thisMonthLabels, labels: catLabels,
datasets: [{ datasets: [{
label: 'Amount (€)', data: catData.map(v => Math.abs(v) / 100),
data: thisMonthData.map(v => Math.abs(v) / 100), backgroundColor: resolvedColors.map(c => c + '99'),
backgroundColor: resolvedColors.map(c => c + 'cc'),
borderColor: resolvedColors, borderColor: resolvedColors,
borderWidth: 1, borderWidth: 1.5,
borderRadius: 5, borderRadius: 6,
borderSkipped: false, borderSkipped: false,
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
animation: { duration: 1000, easing: 'easeOutQuart' }, animation: { duration: 900, easing: 'easeOutQuart' },
plugins: { legend: { display: false } }, plugins: { legend: { display: false } },
scales: { scales: {
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } }, y: { beginAtZero: true, grid: { color: gridColor() }, ticks: { color: textColor(), callback: v => '€' + v } },
x: { grid: { display: false } } x: { grid: { display: false }, ticks: { color: textColor() } }
} }
} }
}); });
{{end}} {{end}}
{{if $d.BalanceTrend}} {{if $d.BalanceTrend}}
const grad = document.createElement('canvas').getContext('2d').createLinearGradient(0,0,0,300); const ctx = document.getElementById('balanceChart').getContext('2d');
grad.addColorStop(0, 'rgba(57,73,171,0.35)'); const grad = ctx.createLinearGradient(0, 0, 0, 260);
grad.addColorStop(0.6, 'rgba(57,73,171,0.1)'); grad.addColorStop(0, 'rgba(105,121,248,0.30)');
grad.addColorStop(1, 'rgba(57,73,171,0.01)'); grad.addColorStop(0.6, 'rgba(105,121,248,0.08)');
new Chart(document.getElementById('balanceChart'), { grad.addColorStop(1, 'rgba(105,121,248,0.00)');
new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: [{{range $d.BalanceTrend}}"{{dateShort .Date}}",{{end}}], labels: [{{range $d.BalanceTrend}}"{{dateShort .Date}}",{{end}}],
datasets: [{ datasets: [{
label: 'Balance (€)', label: 'Balance (€)',
data: [{{range $d.BalanceTrend}}{{div .Cents 100}},{{end}}], data: [{{range $d.BalanceTrend}}{{div .Cents 100}},{{end}}],
borderColor: '#3949ab', borderColor: '#6979f8',
backgroundColor: grad, backgroundColor: grad,
fill: true, fill: true,
tension: 0.4, tension: 0.42,
pointRadius: 0, pointRadius: 0,
pointHoverRadius: 5, pointHoverRadius: 5,
pointBackgroundColor: '#fff', pointBackgroundColor: '#fff',
pointBorderColor: '#3949ab', pointBorderColor: '#6979f8',
pointBorderWidth: 2, pointBorderWidth: 2,
borderWidth: 2.5,
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
animation: { duration: 1200, easing: 'easeOutQuart' }, animation: { duration: 1100, easing: 'easeOutQuart' },
plugins: { legend: { display: false } }, plugins: { legend: { display: false } },
scales: { scales: {
y: { beginAtZero: false, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } }, y: { beginAtZero: false, grid: { color: gridColor() }, ticks: { color: textColor(), callback: v => '€' + v } },
x: { grid: { display: false }, ticks: { maxTicksLimit: 8 } } x: { grid: { display: false }, ticks: { color: textColor(), maxTicksLimit: 7 } }
}, },
interaction: { intersect: false, mode: 'index' } interaction: { intersect: false, mode: 'index' }
} }

View File

@ -1,31 +1,54 @@
{{define "content"}} {{define "content"}}
{{$d := .}} {{$d := .}}
<h1 style="margin-bottom: 24px;">Import Transactions</h1> <h1 style="margin-bottom:24px;">Import</h1>
{{if $d.Error}}
<div class="card" style="border-color:var(--red); background:var(--red-dim);">
<span style="color:var(--red); font-size:13.5px;">⚠ {{$d.Error}}</span>
</div>
{{end}}
{{if $d.Preview}} {{if $d.Preview}}
<div class="card"> <!-- Preview table -->
<h2>Preview — {{$d.Preview.Total}} rows</h2> <div class="card animate-on-scroll">
<form method="POST" action="/import/confirm" class="mt-16"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; flex-wrap:wrap; gap:8px;">
<div>
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0;">Preview</h2>
<p class="text-muted" style="margin-top:3px;">{{$d.Preview.Total}} rows — review categories before confirming.</p>
</div>
<a href="/import" class="btn btn-outline btn-sm">← Back</a>
</div>
<form method="POST" action="/import/confirm">
<input type="hidden" name="account_id" value="{{$d.Preview.AccountID}}"> <input type="hidden" name="account_id" value="{{$d.Preview.AccountID}}">
<input type="hidden" name="format" value="{{$d.SelectedFormat}}"> <input type="hidden" name="format" value="{{$d.SelectedFormat}}">
<input type="hidden" name="raw_data" value="{{$d.RawData}}"> <input type="hidden" name="raw_data" value="{{$d.RawData}}">
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>Date</th><th>Description</th><th class="text-right">Amount</th><th>Category</th></tr> <tr>
<th>Date</th>
<th>Description</th>
<th class="text-right">Amount</th>
<th>Category</th>
</tr>
</thead> </thead>
<tbody> <tbody>
{{range $i, $row := $d.Preview.Rows}} {{range $i, $row := $d.Preview.Rows}}
<tr> <tr>
<td>{{$row.Date}}</td> <td style="white-space:nowrap; color:var(--text2);">{{$row.Date}}</td>
<td>{{$row.Description}}</td> <td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{$row.Description}}</td>
<td class="cents {{if lt $row.AmountCents 0}}negative{{else}}positive{{end}}"> <td class="cents {{if lt $row.AmountCents 0}}negative{{else}}positive{{end}}" style="font-weight:600; white-space:nowrap;">
€{{cents $row.AmountCents}} {{if lt $row.AmountCents 0}}{{else}}+{{end}}€{{cents (centsAbs $row.AmountCents)}}
</td> </td>
<td> <td>
<select name="categories" class="cat-select" data-color=""> <select name="categories" class="cat-select" data-selected="{{$row.Category}}"
style="font-size:12.5px; padding:5px 8px; border:1.5px solid var(--border2);
border-radius:6px; background:var(--bg2); color:var(--text);
border-left-width:4px; cursor:pointer; min-width:130px;">
{{range $cat := $d.Categories}} {{range $cat := $d.Categories}}
<option value="{{$cat}}" data-color="{{index $d.CategoryColors $cat}}" {{if eq $cat $row.Category}}selected{{end}}>{{$cat}}</option> <option value="{{$cat}}" {{if eq $cat $row.Category}}selected{{end}}>{{$cat}}</option>
{{end}} {{end}}
</select> </select>
</td> </td>
@ -34,83 +57,82 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex flex-wrap" style="gap: 8px; margin-top: 16px;">
<button type="submit" class="btn btn-primary">Confirm Import</button> <div style="display:flex; gap:10px; margin-top:18px; flex-wrap:wrap;">
<button type="submit" class="btn btn-primary">✓ Confirm Import</button>
<a href="/import" class="btn btn-outline">Cancel</a> <a href="/import" class="btn btn-outline">Cancel</a>
</div> </div>
</form> </form>
</div> </div>
<script>
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
document.querySelectorAll('.cat-select').forEach(sel => {
function paint() {
const color = catColors[sel.value] || '';
sel.style.borderLeftColor = color || 'var(--border2)';
}
sel.addEventListener('change', paint);
paint();
});
</script>
{{else}} {{else}}
<div class="card"> <!-- Upload forms -->
<div class="grid-2" style="align-items:start;">
<div class="card animate-on-scroll">
<h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0; margin-bottom:18px;">
Bank Transactions
</h2>
<form method="POST" action="/import/preview" enctype="multipart/form-data"> <form method="POST" action="/import/preview" enctype="multipart/form-data">
<div class="form-group"> <div class="form-group">
<label>Account</label> <label>Account</label>
<select name="account_id" required> <select name="account_id" required>
<option value="">Select account...</option> <option value="">Select account</option>
{{range $d.Accounts}} {{range $d.Accounts}}
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option> <option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
{{end}} {{end}}
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Bank / Format</label> <label>Bank / Format</label>
<select name="format" required> <select name="format">
<option value="cgd">Caixa Geral de Depósitos (CGD)</option> <option value="cgd">Caixa Geral de Depósitos (CGD)</option>
<option value="traderepublic">Trade Republic (Card)</option> <option value="traderepublic">Trade Republic Card</option>
<option value="generic">Generic CSV</option> <option value="generic">Generic CSV</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>CSV File</label> <label>CSV File</label>
<input type="file" name="file" accept=".csv,.txt" required> <input type="file" name="file" accept=".csv,.txt" required
style="padding:10px; cursor:pointer; font-size:13px;">
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">Preview Import</button>
</form>
</div> </div>
<button type="submit" class="btn btn-primary">Preview</button> <div class="card animate-on-scroll">
</form> <h2 style="font-size:15px; font-weight:700; color:var(--text); text-transform:none; letter-spacing:0; margin-bottom:18px;">
Securities Trades
<hr style="margin: 24px 0;"> </h2>
<p class="text-muted" style="margin-bottom:16px; font-size:13px; line-height:1.6;">
<h2 class="mb-16">Import Securities Trades</h2> Upload your Trade Republic securities CSV to import buy/sell trades into your portfolio.
</p>
<form method="POST" action="/import/securities" enctype="multipart/form-data"> <form method="POST" action="/import/securities" enctype="multipart/form-data">
<div class="form-group"> <div class="form-group">
<label>Trade Republic Securities CSV</label> <label>Trade Republic Securities CSV</label>
<input type="file" name="file" accept=".csv,.txt" required> <input type="file" name="file" accept=".csv,.txt" required
style="padding:10px; cursor:pointer; font-size:13px;">
</div> </div>
<button type="submit" class="btn btn-primary">Import Securities</button> <button type="submit" class="btn btn-primary" style="width:100%;">Import Trades</button>
</form> </form>
<div style="margin-top:20px; padding-top:16px; border-top:1px solid var(--border);">
<p class="text-muted" style="font-size:12px; line-height:1.6;">
After importing, visit <a href="/portfolio" style="color:var(--accent);">Portfolio</a> to see live prices and P&L.
</p>
</div>
</div>
</div> </div>
{{end}} {{end}}
{{if $d.Error}}
<div class="card error">{{$d.Error}}</div>
{{end}}
<style>
hr { border: none; border-top: 1px solid #e0e0e0; }
.cat-select {
padding: 4px 8px; border: 1px solid #ddd; border-radius: 6px;
font-size: 13px; background: #fff; cursor: pointer; min-width: 120px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
border-left: 4px solid #ddd;
}
.cat-select:focus {
outline: none; border-color: #3949ab; box-shadow: 0 0 0 3px rgba(57,73,171,0.1);
}
.cents.positive { color: #4caf50; font-weight: 600; }
.cents.negative { color: #f44336; }
</style>
<script>
document.querySelectorAll('.cat-select').forEach(function(sel) {
function updateColor() {
var opt = sel.options[sel.selectedIndex];
var color = opt.getAttribute('data-color') || '#ddd';
sel.style.borderLeftColor = color;
}
sel.addEventListener('change', updateColor);
updateColor();
});
</script>
{{end}} {{end}}

View File

@ -3,60 +3,68 @@
<h1 style="margin-bottom:24px;">Portfolio</h1> <h1 style="margin-bottom:24px;">Portfolio</h1>
{{if $d.Holdings}} {{if $d.Holdings}}
<div class="grid"> <div class="grid">
<div class="card value-card"> <div class="card value-card animate-on-scroll">
<h2>Total Value</h2> <h2>Total Value</h2>
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}}"> <div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}} animate-counter"
€{{cents $d.TotalValueCents}} data-target="{{$d.TotalValueCents}}" data-prefix="€">€0.00</div>
</div> </div>
</div> <div class="card value-card animate-on-scroll">
<div class="card value-card">
<h2>Total Cost</h2> <h2>Total Cost</h2>
<div class="value">€{{cents $d.TotalCostCents}}</div> <div class="value animate-counter" data-target="{{$d.TotalCostCents}}" data-prefix="€"
style="color:var(--text);">€0.00</div>
</div> </div>
<div class="card value-card"> <div class="card value-card animate-on-scroll">
<h2>Unrealized P&L</h2> <h2>Unrealized P&amp;L</h2>
<div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}}"> <div class="value {{if ge $d.TotalPCLCents 0}}positive{{else}}negative{{end}} animate-counter"
{{pctSign $d.TotalPCLPct}}€{{cents $d.TotalPCLCents}} data-target="{{$d.TotalPCLCents}}" data-prefix="€">€0.00</div>
({{pctSign $d.TotalPCLPct}}{{printf "%.2f" $d.TotalPCLPct}}%) <p style="font-size:13px; margin-top:6px; {{if ge $d.TotalPCLPct 0}}color:var(--green){{else}}color:var(--red){{end}};">
</div> {{pctSign $d.TotalPCLPct}}{{printf "%.2f" $d.TotalPCLPct}}%
</div> </p>
</div>
<div class="card">
<h2>Allocation</h2>
<div style="max-width: 500px; margin: 0 auto; position: relative;">
<canvas id="allocationChart2d" height="300" style="display:none;"></canvas>
<div id="allocation3d" style="width:100%; height:400px;"></div>
</div> </div>
</div> </div>
<div class="card table-wrap"> <div class="grid-2" style="align-items:start;">
<!-- Allocation donut -->
<div class="card animate-on-scroll">
<h2 style="margin-bottom:16px;">Allocation</h2>
<div id="allocation3d" style="width:100%; height:380px; position:relative;"></div>
</div>
<!-- Holdings table -->
<div class="card animate-on-scroll">
<h2 style="margin-bottom:14px;">Holdings</h2>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Stock</th> <th>Asset</th>
<th>Shares</th> <th class="text-right">Shares</th>
<th class="text-right">Avg Entry</th> <th class="text-right">Avg Cost</th>
<th class="text-right">Current Price</th> <th class="text-right">Price</th>
<th class="text-right">Value</th> <th class="text-right">Value</th>
<th class="text-right">P&L</th> <th class="text-right">P&amp;L</th>
<th class="text-right">Return</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range $i, $h := $d.Holdings}} {{range $i, $h := $d.Holdings}}
<tr> <tr>
<td><strong>{{.Name}}</strong><br><span class="text-muted">{{.ISIN}}</span></td> <td>
<td>{{printf "%.4f" .SharesOwned}}</td> <div style="font-weight:600; font-size:13.5px;">{{.Name}}</div>
<td class="cents">€{{cents .AvgEntryCents}}</td> <div class="text-muted" style="font-size:11.5px; margin-top:1px;">{{.ISIN}}</div>
<td class="cents">€{{cents .CurrentPriceCents}}</td>
<td class="cents">€{{cents .CurrentValueCents}}</td>
<td class="cents {{if ge .UnrealizedPCLCents 0}}positive{{else}}negative{{end}}">
€{{cents .UnrealizedPCLCents}}
</td> </td>
<td class="cents {{if ge .UnrealizedPCLPct 0}}positive{{else}}negative{{end}}"> <td class="cents" style="font-size:13px; color:var(--text2);">{{printf "%.4f" .SharesOwned}}</td>
<td class="cents" style="font-size:13px;">€{{cents .AvgEntryCents}}</td>
<td class="cents" style="font-size:13px;">€{{cents .CurrentPriceCents}}</td>
<td class="cents" style="font-weight:600;">€{{cents .CurrentValueCents}}</td>
<td class="cents">
<div class="{{if ge .UnrealizedPCLCents 0}}positive{{else}}negative{{end}}" style="font-weight:600; font-size:13px;">
{{pctSign .UnrealizedPCLPct}}{{printf "%.2f" .UnrealizedPCLPct}}% {{pctSign .UnrealizedPCLPct}}{{printf "%.2f" .UnrealizedPCLPct}}%
</div>
<div class="{{if ge .UnrealizedPCLCents 0}}positive{{else}}negative{{end}}" style="font-size:11.5px; opacity:0.8;">
{{if ge .UnrealizedPCLCents 0}}+{{else}}{{end}}€{{cents (centsAbs .UnrealizedPCLCents)}}
</div>
</td> </td>
</tr> </tr>
{{end}} {{end}}
@ -64,6 +72,13 @@
</table> </table>
</div> </div>
<div style="margin-top:16px; padding-top:14px; border-top:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;">
<span class="text-muted">Add trades via</span>
<a href="/import" class="btn btn-outline btn-sm">Import CSV</a>
</div>
</div>
</div>
<script type="importmap"> <script type="importmap">
{ {
"imports": { "imports": {
@ -76,191 +91,133 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const colors = ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#00E676','#651FFF','#FF6F00','#E91E63','#607D8B','#3F51B5','#9E9E9E']; const palette = [
'#6979f8','#f87171','#fbbf24','#34d399','#a78bfa',
'#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9',
];
const holdings = [ const holdings = [
{{range $d.Holdings}} {{range $d.Holdings}}{ name: "{{.Name}}", value: {{.CurrentValueCents}} },{{end}}
{ name: "{{.Name}}", value: {{.CurrentValueCents}} },
{{end}}
]; ];
const total = holdings.reduce((s, h) => s + h.value, 0); const total = holdings.reduce((s, h) => s + h.value, 0);
if (total > 0) { if (total <= 0) return;
const container = document.getElementById('allocation3d'); const container = document.getElementById('allocation3d');
const w = container.clientWidth; const W = container.clientWidth, H = 380;
const h = 400;
const scene = new THREE.Scene(); const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 100); const camera = new THREE.PerspectiveCamera(42, W / H, 0.1, 100);
camera.position.set(0, 4, 8); camera.position.set(0, 5, 9);
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(w, h); renderer.setSize(W, H);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true; renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement); container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.enableDamping = true;
controls.dampingFactor = 0.08; controls.dampingFactor = 0.07;
controls.autoRotate = true; controls.autoRotate = true;
controls.autoRotateSpeed = 1.5; controls.autoRotateSpeed = 1.2;
controls.minDistance = 4; controls.minDistance = 5;
controls.maxDistance = 15; controls.maxDistance = 16;
controls.target.set(0, 0, 0);
// tooltip
const tip = Object.assign(document.createElement('div'), {
style: 'position:absolute;background:rgba(15,17,23,0.92);color:#e8eaf6;padding:7px 13px;border-radius:8px;font-size:13px;pointer-events:none;opacity:0;transition:opacity .15s;z-index:10;border:1px solid rgba(255,255,255,0.08);backdrop-filter:blur(8px);'
});
container.appendChild(tip);
const group = new THREE.Group(); const group = new THREE.Group();
const IR = 1.3, OR = 3.0, D = 0.55;
const innerRadius = 1.2; let angle = 0, hovered = null;
const outerRadius = 2.8;
const depth = 0.6;
let angle = 0;
const tooltip = document.createElement('div');
tooltip.style.cssText = 'position:absolute;background:rgba(0,0,0,0.8);color:#fff;padding:6px 12px;border-radius:6px;font-size:13px;pointer-events:none;opacity:0;transition:opacity 0.2s;z-index:10;';
container.appendChild(tooltip);
const raycaster = new THREE.Raycaster(); const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(); const pointer = new THREE.Vector2();
let hovered = null; const meshes = [];
holdings.forEach((h, i) => { holdings.forEach((h, i) => {
const arcAngle = (h.value / total) * Math.PI * 2; const arc = (h.value / total) * Math.PI * 2;
const color = colors[i % colors.length]; const color = palette[i % palette.length];
const mid = angle + arc / 2;
const shape = new THREE.Shape(); const shape = new THREE.Shape();
const segments = 32; shape.moveTo(IR, 0);
const startAngle = angle; shape.absarc(0, 0, OR, angle, angle + arc, false);
const endAngle = angle + arcAngle; shape.absarc(0, 0, IR, angle + arc, angle, true);
shape.moveTo(innerRadius, 0);
shape.absarc(0, 0, outerRadius, startAngle, endAngle, false);
shape.absarc(0, 0, innerRadius, endAngle, startAngle, true);
shape.closePath(); shape.closePath();
const extrudeSettings = { const geo = new THREE.ExtrudeGeometry(shape, {
depth: depth, depth: D, bevelEnabled: true, bevelThickness: 0.07, bevelSize: 0.04, bevelSegments: 6,
bevelEnabled: true,
bevelThickness: 0.08,
bevelSize: 0.04,
bevelSegments: 8,
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const material = new THREE.MeshPhysicalMaterial({
color: color,
metalness: 0.2,
roughness: 0.3,
clearcoat: 0.1,
side: THREE.DoubleSide,
}); });
const mesh = new THREE.Mesh(geometry, material); const mat = new THREE.MeshPhysicalMaterial({
color, metalness: 0.15, roughness: 0.35, clearcoat: 0.15, side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI / 2; mesh.rotation.x = -Math.PI / 2;
mesh.position.y = -depth / 2; mesh.position.y = -D / 2;
mesh.userData = { holding: h }; mesh.userData = { holding: h, baseY: -D / 2, color, pct: (h.value / total * 100).toFixed(1) };
mesh.castShadow = true; mesh.castShadow = true;
mesh.receiveShadow = true;
group.add(mesh); group.add(mesh);
meshes.push(mesh);
const edgeGeo = new THREE.EdgesGeometry(geometry); angle += arc;
const edgeMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.15 });
const edge = new THREE.LineSegments(edgeGeo, edgeMat);
edge.rotation.x = -Math.PI / 2;
edge.position.y = -depth / 2;
group.add(edge);
angle = endAngle;
}); });
scene.add(group); scene.add(group);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(Object.assign(new THREE.AmbientLight(0xffffff, 0.55)));
scene.add(ambientLight); const dir = new THREE.DirectionalLight(0xffffff, 1.1);
dir.position.set(5, 10, 7); dir.castShadow = true; scene.add(dir);
const fill = new THREE.DirectionalLight(0x8b9ffc, 0.4);
fill.position.set(-5, 2, -5); scene.add(fill);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); renderer.domElement.addEventListener('pointermove', e => {
dirLight.position.set(5, 10, 7);
dirLight.castShadow = true;
scene.add(dirLight);
const backLight = new THREE.DirectionalLight(0xffffff, 0.4);
backLight.position.set(-5, 0, -5);
scene.add(backLight);
const floorGeo = new THREE.RingGeometry(1.5, 3.5, 64);
const floorMat = new THREE.MeshPhysicalMaterial({
color: 0x1a237e,
transparent: true,
opacity: 0.06,
side: THREE.DoubleSide,
roughness: 0.8,
metalness: 0.1,
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -depth / 2 - 0.01;
scene.add(floor);
function onPointerMove(event) {
const rect = renderer.domElement.getBoundingClientRect(); const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera); raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(group.children); const hits = raycaster.intersectObjects(meshes);
if (hits.length && hits[0].object.userData.holding) {
const m = hits[0].object;
const h = m.userData.holding;
tip.textContent = `${h.name}: €${(h.value/100).toLocaleString('pt-PT',{minimumFractionDigits:2})} (${m.userData.pct}%)`;
tip.style.opacity = '1';
tip.style.left = (e.clientX - rect.left + 14) + 'px';
tip.style.top = (e.clientY - rect.top - 10) + 'px';
if (hovered !== m) {
if (hovered) { hovered.position.y = hovered.userData.baseY; }
m.position.y = m.userData.baseY + 0.25;
hovered = m;
}
} else {
tip.style.opacity = '0';
if (hovered) { hovered.position.y = hovered.userData.baseY; hovered = null; }
}
});
if (intersects.length > 0) { (function animate() {
const hit = intersects[0].object;
if (hit.userData.holding) {
const h = hit.userData.holding;
const pct = ((h.value / total) * 100).toFixed(1);
tooltip.textContent = `${h.name}: €${(h.value / 100).toFixed(2)} (${pct}%)`;
tooltip.style.opacity = '1';
tooltip.style.left = (event.clientX - rect.left + 12) + 'px';
tooltip.style.top = (event.clientY - rect.top - 10) + 'px';
if (hovered !== hit) {
if (hovered) {
hovered.material.opacity = 1;
hovered.material.needsUpdate = true;
}
hit.material.opacity = 0.85;
hit.material.needsUpdate = true;
hovered = hit;
}
return;
}
}
tooltip.style.opacity = '0';
if (hovered) {
hovered.material.opacity = 1;
hovered.material.needsUpdate = true;
hovered = null;
}
}
renderer.domElement.addEventListener('pointermove', onPointerMove);
function animate() {
requestAnimationFrame(animate); requestAnimationFrame(animate);
controls.update(); controls.update();
renderer.render(scene, camera); renderer.render(scene, camera);
} })();
animate();
function resize() { window.addEventListener('resize', () => {
const w2 = container.clientWidth; const w2 = container.clientWidth;
camera.aspect = w2 / h; camera.aspect = w2 / H;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
renderer.setSize(w2, h); renderer.setSize(w2, H);
} });
window.addEventListener('resize', resize);
}
</script> </script>
{{else}} {{else}}
<div class="card empty-state"> <div class="card empty-state animate-on-scroll">
<h3>No trades imported yet</h3> <div style="font-size:48px; margin-bottom:16px;">📈</div>
<p>Go to <a href="/import">Import</a> and upload your Trade Republic securities CSV.</p> <h3>No trades yet</h3>
<p style="margin-bottom:20px;">Import your Trade Republic securities CSV to see your portfolio.</p>
<a href="/import" class="btn btn-primary">Import Trades</a>
</div> </div>
{{end}} {{end}}
{{end}} {{end}}

View File

@ -3,60 +3,88 @@
<h1 style="margin-bottom:24px;">Projections</h1> <h1 style="margin-bottom:24px;">Projections</h1>
<div class="grid"> <div class="grid">
<div class="card"> <div class="card value-card animate-on-scroll">
<h2>Projected Annual Spend</h2> <h2>Projected Annual Spend</h2>
<div class="value negative">€{{cents $d.AnnualTotal}}</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>
</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>
</div> </div>
</div> </div>
<div class="card animate-on-scroll"> <div class="card animate-on-scroll">
<h2>Monthly Average Spend (Last 6 Months)</h2> <h2 style="margin-bottom:12px;">Monthly Average by Category — Last 6 Months</h2>
<canvas id="projChart" height="250"></canvas> <div style="padding-top:4px;">
<canvas id="projChart" height="260"></canvas>
</div>
</div> </div>
<div class="card table-wrap"> <div class="card animate-on-scroll">
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<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>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range $cat, $avg := $d.MonthlyAvg}} {{range $cat, $avg := $d.MonthlyAvg}}
<tr> <td style="font-weight:500;">{{$cat}}</td>
<td>{{$cat}}</td> <td class="cents negative">€{{printf "%.2f" $avg}}</td>
<td class="cents">€{{printf "%.2f" $avg}}</td> <td class="cents negative">€{{printf "%.2f" (mul $avg 12)}}</td>
<td class="cents">€{{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>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<script> <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 = [
'#6979f8','#f87171','#fbbf24','#34d399','#a78bfa',
'#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9',
'#94a3b8','#22d3ee','#f97316','#a3e635',
];
new Chart(document.getElementById('projChart'), { new Chart(document.getElementById('projChart'), {
type: 'bar', type: 'bar',
data: { data: {
labels: [{{range $cat, $_ := $d.MonthlyAvg}}"{{$cat}}",{{end}}], labels: [{{range $cat, $_ := $d.MonthlyAvg}}"{{$cat}}",{{end}}],
datasets: [{ datasets: [{
label: 'Monthly Avg (€)', label: 'Monthly Avg (€)',
data: [{{range $cat, $avg := $d.MonthlyAvg}}{{$avg}},{{end}}], data: [{{range $_, $avg := $d.MonthlyAvg}}{{$avg}},{{end}}],
backgroundColor: ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#00E676','#651FFF','#FF6F00','#E91E63','#607D8B','#3F51B5','#9E9E9E'], backgroundColor: catColors.map(c => c + '99'),
borderColor: ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#00E676','#651FFF','#FF6F00','#E91E63','#607D8B','#3F51B5','#9E9E9E'], borderColor: catColors,
borderWidth: 1, borderWidth: 1.5,
borderRadius: 4, borderRadius: 6,
borderSkipped: false, borderSkipped: false,
}] }]
}, },
options: { options: {
responsive: true, responsive: true,
animation: { duration: 1200, easing: 'easeOutQuart' }, animation: { duration: 900, easing: 'easeOutQuart' },
plugins: { legend: { display: false } }, plugins: { legend: { display: false } },
scales: { scales: {
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } }, y: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } },
x: { grid: { display: false } } x: { grid: { display: false }, ticks: { color: textC() } }
} }
} }
}); });

View File

@ -4,18 +4,22 @@
<div class="card animate-on-scroll"> <div class="card animate-on-scroll">
<h2>12-Month Spend by Category</h2> <h2>12-Month Spend by Category</h2>
<canvas id="reportChart" height="300"></canvas> <div style="padding-top:8px;">
<canvas id="reportChart" height="280"></canvas>
</div>
</div> </div>
<div class="card table-wrap animate-on-scroll" style="overflow-x:auto;"> <div class="card animate-on-scroll" style="overflow-x:auto;">
<h2 style="margin-bottom:14px;">Breakdown by Month</h2>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Month</th> <th>Month</th>
{{range $cat, $_ := $d.CategoryNames}} {{range $cat, $_ := $d.CategoryNames}}
<th class="text-right" style="white-space:nowrap;">
{{$color := index $d.CategoryColors $cat}} {{$color := index $d.CategoryColors $cat}}
{{if $color}}<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:{{$color}};margin-right:4px;vertical-align:middle;"></span>{{end}}{{$cat}} <th class="text-right" style="white-space:nowrap;">
{{if $color}}<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:{{$color}};margin-right:4px;vertical-align:middle;box-shadow:0 0 4px {{$color}}88;"></span>{{end}}{{$cat}}
</th> </th>
{{end}} {{end}}
<th class="text-right">Total</th> <th class="text-right">Total</th>
@ -23,24 +27,31 @@
</thead> </thead>
<tbody> <tbody>
{{range $row := $d.MonthlyData}} {{range $row := $d.MonthlyData}}
{{$total := sub 0 0}}
{{range $_, $v := $row.Totals}}{{$total = add $total $v}}{{end}}
<tr> <tr>
<td><strong>{{$row.Month}}</strong></td> <td style="font-weight:600; white-space:nowrap;">{{$row.Month}}</td>
{{range $cat, $_ := $d.CategoryNames}} {{range $cat, $_ := $d.CategoryNames}}
<td class="cents">
{{$v := index $row.Totals $cat}} {{$v := index $row.Totals $cat}}
<td class="cents">
{{if $v}}<span class="{{if lt $v 0}}negative{{else}}positive{{end}}">€{{cents $v}}</span>{{else}}<span class="text-muted"></span>{{end}} {{if $v}}<span class="{{if lt $v 0}}negative{{else}}positive{{end}}">€{{cents $v}}</span>{{else}}<span class="text-muted"></span>{{end}}
</td> </td>
{{end}} {{end}}
{{$total := sub 0 0}} <td class="cents" style="font-weight:600;">
{{range $_, $v := $row.Totals}}{{$total = add $total $v}}{{end}} <span class="{{if lt $total 0}}negative{{else}}positive{{end}}">€{{cents $total}}</span>
<td class="cents"><strong class="{{if lt $total 0}}negative{{else}}positive{{end}}">€{{cents $total}}</strong></td> </td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
<script> <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';
new Chart(document.getElementById('reportChart'), { new Chart(document.getElementById('reportChart'), {
type: 'bar', type: 'bar',
data: { data: {
@ -51,24 +62,24 @@ new Chart(document.getElementById('reportChart'), {
{ {
label: '{{$cat}}', label: '{{$cat}}',
data: [{{range $d.MonthlyData}}{{$v := index .Totals $cat}}{{if $v}}{{abs $v | div 100}}{{else}}0{{end}},{{end}}], data: [{{range $d.MonthlyData}}{{$v := index .Totals $cat}}{{if $v}}{{abs $v | div 100}}{{else}}0{{end}},{{end}}],
backgroundColor: '{{index $colors $cat}}', backgroundColor: '{{index $colors $cat}}cc',
borderColor: '{{index $colors $cat}}', borderColor: '{{index $colors $cat}}',
borderWidth: 0, borderWidth: 0,
borderRadius: 2, borderRadius: 3,
}, },
{{end}} {{end}}
] ]
}, },
options: { options: {
responsive: true, responsive: true,
animation: { duration: 1000, easing: 'easeOutQuart' }, animation: { duration: 900, easing: 'easeOutQuart' },
plugins: { plugins: {
legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16 } }, legend: { position: 'bottom', labels: { boxWidth: 11, padding: 14, color: textC(), usePointStyle: true, pointStyle: 'circle' } },
tooltip: { mode: 'index', intersect: false } tooltip: { mode: 'index', intersect: false }
}, },
scales: { scales: {
x: { stacked: true, grid: { display: false } }, x: { stacked: true, grid: { display: false }, ticks: { color: textC() } },
y: { stacked: true, beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } } y: { stacked: true, beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } }
} }
} }
}); });

View File

@ -2,88 +2,115 @@
{{$d := .}} {{$d := .}}
<h1 style="margin-bottom:24px;">Sharing</h1> <h1 style="margin-bottom:24px;">Sharing</h1>
<div class="card"> <div class="card animate-on-scroll">
<h2>Grant Read Access</h2> <h2 style="margin-bottom:16px;">Grant Read Access</h2>
<form method="POST" class="flex" style="gap: 12px; align-items: end;"> <form method="POST" class="flex flex-wrap" style="gap:10px; align-items:flex-end; position:relative;">
<div class="form-group" style="margin-bottom: 0; flex: 1;"> <div class="form-group" style="margin-bottom:0; flex:1; min-width:220px; position:relative;">
<label>User Email</label> <label>User Email</label>
<input type="text" name="viewer_id" id="viewerSearch" placeholder="Search by email..." required> <input type="text" name="viewer_id" id="viewerSearch"
<div id="searchResults" style="display:none;"></div> placeholder="Search by email…" autocomplete="off" required>
<div id="searchResults"></div>
</div> </div>
<button type="submit" class="btn btn-primary">Grant Access</button> <button type="submit" class="btn btn-primary">Grant Access</button>
</form> </form>
</div> </div>
<div class="card"> <div class="grid-2">
<h2>People with Access to My Finances</h2> <div class="card animate-on-scroll">
<h2 style="margin-bottom:14px;">People with access to my finances</h2>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>User</th><th>Granted</th><th></th></tr> <tr><th>User</th><th>Since</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{{range $d.Grants}} {{range $d.Grants}}
<tr> <tr>
<td>{{.ViewerID}}</td> <td style="font-size:13px;">{{.ViewerID}}</td>
<td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td> <td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td>
<td> <td class="text-right">
<button class="btn btn-danger btn-sm" onclick="revoke('{{.ViewerID}}')">Revoke</button> <button class="btn btn-danger btn-sm" onclick="revoke('{{.ViewerID}}')">Revoke</button>
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="3" class="text-center text-muted">No access grants yet.</td></tr> <tr><td colspan="3" class="text-center text-muted" style="padding:28px;">No access grants yet.</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="card"> <div class="card animate-on-scroll">
<h2>Access Granted to Me</h2> <h2 style="margin-bottom:14px;">Access granted to me</h2>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>Owner</th><th>Granted</th></tr> <tr><th>Owner</th><th>Since</th></tr>
</thead> </thead>
<tbody> <tbody>
{{range $d.Granted}} {{range $d.Granted}}
<tr> <tr>
<td>{{.OwnerID}}</td> <td style="font-size:13px;">{{.OwnerID}}</td>
<td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td> <td class="text-muted">{{.CreatedAt.Format "02 Jan 2006"}}</td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="2" class="text-center text-muted">No one has granted you access yet.</td></tr> <tr><td colspan="2" class="text-center text-muted" style="padding:28px;">No one has shared with you yet.</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div>
<style>
#searchResults {
position: absolute;
top: 100%; left: 0; right: 0;
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
max-height: 200px;
overflow-y: auto;
z-index: 50;
display: none;
}
#searchResults div {
padding: 9px 12px;
font-size: 13.5px;
cursor: pointer;
border-bottom: 1px solid var(--border);
color: var(--text);
transition: background 0.12s;
}
#searchResults div:last-child { border-bottom: none; }
#searchResults div:hover { background: var(--bg3); }
</style>
<script> <script>
const searchInput = document.getElementById('viewerSearch');
const resultsDiv = document.getElementById('searchResults');
let searchTimer; let searchTimer;
const input = document.getElementById('viewerSearch');
const results = document.getElementById('searchResults');
searchInput.addEventListener('input', function() { input.addEventListener('input', () => {
clearTimeout(searchTimer); clearTimeout(searchTimer);
const q = this.value.trim(); const q = input.value.trim();
if (q.length < 2) { resultsDiv.style.display = 'none'; return; } if (q.length < 2) { results.style.display = 'none'; return; }
searchTimer = setTimeout(() => { searchTimer = setTimeout(() => {
fetch('/api/users/search?q=' + encodeURIComponent(q)) fetch('/api/users/search?q=' + encodeURIComponent(q))
.then(r => r.json()) .then(r => r.json())
.then(users => { .then(users => {
if (!users.length) { resultsDiv.style.display = 'none'; return; } if (!users || !users.length) { results.style.display = 'none'; return; }
resultsDiv.innerHTML = users.map(u => results.innerHTML = users.map(u =>
`<div onclick="selectUser('${u.id}','${u.email}')" style="padding:6px 12px;cursor:pointer;border-bottom:1px solid #eee;">${u.email}</div>` `<div onclick="selectUser('${u.id}','${u.email}')">${u.email}</div>`
).join(''); ).join('');
resultsDiv.style.display = 'block'; results.style.display = 'block';
}); });
}, 300); }, 280);
}); });
function selectUser(id, email) { function selectUser(id, email) {
searchInput.value = email; input.value = email;
resultsDiv.style.display = 'none'; results.style.display = 'none';
} }
function revoke(viewerId) { function revoke(viewerId) {
@ -91,12 +118,9 @@ function revoke(viewerId) {
fetch('/sharing/' + encodeURIComponent(viewerId), {method: 'DELETE'}) fetch('/sharing/' + encodeURIComponent(viewerId), {method: 'DELETE'})
.then(() => location.reload()); .then(() => location.reload());
} }
document.addEventListener('click', e => {
if (!input.contains(e.target)) results.style.display = 'none';
});
</script> </script>
<style>
#searchResults {
position: absolute; background: #fff; border: 1px solid #ddd;
border-radius: 6px; max-height: 200px; overflow-y: auto; z-index: 100;
width: 100%; box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
</style>
{{end}} {{end}}

View File

@ -6,9 +6,9 @@
</div> </div>
<div class="card mb-16"> <div class="card mb-16">
<form method="GET" class="flex flex-wrap" style="gap:8px; align-items:flex-end;"> <form method="GET" class="flex flex-wrap" style="gap:10px; align-items:flex-end;">
<div class="form-group" style="margin-bottom:0; min-width:160px;"> <div class="form-group" style="margin-bottom:0; min-width:160px;">
<label style="font-size:12px; color:#888;">Category</label> <label>Category</label>
<select name="category"> <select name="category">
<option value="">All Categories</option> <option value="">All Categories</option>
{{range $d.Categories}} {{range $d.Categories}}
@ -16,8 +16,8 @@
{{end}} {{end}}
</select> </select>
</div> </div>
<div class="form-group" style="margin-bottom:0; min-width:120px;"> <div class="form-group" style="margin-bottom:0; min-width:130px;">
<label style="font-size:12px; color:#888;">Period</label> <label>Period</label>
<select name="days"> <select name="days">
<option value="">All time</option> <option value="">All time</option>
<option value="30" {{if eq $.Days "30"}}selected{{end}}>30 days</option> <option value="30" {{if eq $.Days "30"}}selected{{end}}>30 days</option>
@ -26,8 +26,8 @@
</select> </select>
</div> </div>
<div class="form-group" style="margin-bottom:0; flex:1; min-width:200px;"> <div class="form-group" style="margin-bottom:0; flex:1; min-width:200px;">
<label style="font-size:12px; color:#888;">Search</label> <label>Search</label>
<input type="text" name="search" placeholder="Search description..." value="{{.Search}}"> <input type="text" name="search" placeholder="Description…" value="{{.Search}}">
</div> </div>
<button type="submit" class="btn btn-primary">Filter</button> <button type="submit" class="btn btn-primary">Filter</button>
{{if or $.Cat $.Search $.Days}} {{if or $.Cat $.Search $.Days}}
@ -56,26 +56,36 @@
{{range $d.Txns}} {{range $d.Txns}}
{{$color := index $d.CategoryColors .Category}} {{$color := index $d.CategoryColors .Category}}
<tr id="row-{{.ID}}"> <tr id="row-{{.ID}}">
<td style="white-space:nowrap;">{{dateShort .Date}}</td> <td style="white-space:nowrap; color:var(--text2);">{{dateShort .Date}}</td>
<td class="desc-cell">{{.Description}}</td> <td style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
<td class="text-muted" style="font-size:13px;"> <td style="font-size:12.5px; color:var(--text3);">
{{$name := index $d.AccountNames .AccountID}} {{$name := index $d.AccountNames .AccountID}}
{{if $name}}{{$name}}{{else}}<span style="opacity:0.4;"></span>{{end}} {{if $name}}{{$name}}{{else}}<span style="opacity:.35;"></span>{{end}}
</td> </td>
<td> <td>
<span class="cat-badge" id="cat-{{.ID}}" <!-- badge shown normally -->
style="background:{{if $color}}{{$color}}22{{else}}#e0e0e0{{end}}; color:{{if $color}}{{$color}}{{else}}#555{{end}}; border:1px solid {{if $color}}{{$color}}44{{else}}#ccc{{end}}; padding:2px 10px; border-radius:12px; font-size:12px; font-weight:500;"> <span id="cat-{{.ID}}" class="badge" style="
{{if $color}}<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:{{$color}};margin-right:5px;vertical-align:middle;"></span>{{end}}{{.Category}} background:{{if $color}}{{$color}}18{{else}}var(--bg3){{end}};
color:{{if $color}}{{$color}}{{else}}var(--text2){{end}};
border:1px solid {{if $color}}{{$color}}33{{else}}var(--border2){{end}};">
{{if $color}}<span class="category-dot" style="background:{{$color}};box-shadow:0 0 4px {{$color}}88;"></span>{{end}}
{{.Category}}
</span> </span>
<select class="cat-select" id="sel-{{.ID}}" style="display:none; font-size:12px; padding:2px 6px; border-radius:8px; border:1px solid #c5cae9;" <!-- inline dropdown, hidden until pencil clicked -->
onchange="saveCategory('{{.ID}}', this.value)" onblur="cancelEdit('{{.ID}}')"> <select id="sel-{{.ID}}" style="display:none; font-size:12px; padding:4px 8px;
border:1px solid var(--border2); border-radius:6px; background:var(--bg2); color:var(--text);"
onchange="saveCategory('{{.ID}}', this.value)"
onblur="cancelEdit('{{.ID}}')">
{{range $d.Categories}} {{range $d.Categories}}
<option value="{{.Name}}" {{if eq .Name $.Category}}selected{{end}}>{{.Name}}</option> <option value="{{.Name}}">{{.Name}}</option>
{{end}} {{end}}
</select> </select>
<button class="btn btn-outline btn-sm" onclick="editCat('{{.ID}}')" id="edit-btn-{{.ID}}" title="Edit category"></button> <button id="edit-btn-{{.ID}}" class="btn btn-outline btn-sm"
onclick="editCat('{{.ID}}')" title="Edit category"
style="margin-left:4px; padding:3px 7px;">✎</button>
</td> </td>
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="font-variant-numeric:tabular-nums; white-space:nowrap;"> <td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}"
style="font-weight:600; white-space:nowrap;">
{{if lt .AmountCents 0}}{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}} {{if lt .AmountCents 0}}{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
</td> </td>
<td> <td>
@ -83,7 +93,13 @@
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="6" class="text-center text-muted" style="padding:40px;">No transactions found. <a href="/import">Import some</a> or <button class="btn btn-outline btn-sm" onclick="openAddModal()">add manually</button>.</td></tr> <tr>
<td colspan="6" class="text-center text-muted" style="padding:44px;">
No transactions found.
<a href="/import" style="color:var(--accent);">Import some</a> or
<button class="btn btn-outline btn-sm" onclick="openAddModal()" style="margin-left:4px;">add manually</button>.
</td>
</tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
@ -91,9 +107,17 @@
</div> </div>
<!-- Add Transaction Modal --> <!-- Add Transaction Modal -->
<div id="add-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.4); z-index:200; align-items:center; justify-content:center;"> <div id="add-modal" style="display:none; position:fixed; inset:0;
<div class="card" style="width:420px; max-width:95vw; margin:0; animation:fadeIn 0.2s ease-out;"> background:rgba(0,0,0,0.55); backdrop-filter:blur(4px);
<h2 style="font-size:18px; font-weight:700; color:#333; margin-bottom:20px;">Add Transaction</h2> z-index:300; align-items:center; justify-content:center;">
<div class="card" style="width:440px; max-width:95vw; margin:0;
border:1px solid var(--border2); box-shadow:var(--shadow-lg);
animation:fadeUp 0.2s ease-out both;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<span style="font-size:16px; font-weight:700;">Add Transaction</span>
<button class="btn btn-outline btn-sm" onclick="closeAddModal()" style="padding:3px 8px;"></button>
</div>
<div class="form-group"> <div class="form-group">
<label>Date</label> <label>Date</label>
<input type="date" id="add-date" required> <input type="date" id="add-date" required>
@ -104,10 +128,12 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Amount (€)</label> <label>Amount (€)</label>
<div class="flex" style="gap:8px;"> <div style="display:flex; gap:8px;">
<select id="add-sign" style="width:90px; padding:8px; border:1px solid #ddd; border-radius:8px;"> <select id="add-sign" style="width:110px; padding:9px 10px;
<option value="-1">Expense</option> border:1px solid var(--border2); border-radius:var(--radius-sm);
<option value="1">Income</option> background:var(--bg2); color:var(--text);">
<option value="-1"> Expense</option>
<option value="1">+ Income</option>
</select> </select>
<input type="number" id="add-amount" placeholder="0.00" step="0.01" min="0" style="flex:1;"> <input type="number" id="add-amount" placeholder="0.00" step="0.01" min="0" style="flex:1;">
</div> </div>
@ -118,9 +144,7 @@
{{range $d.Categories}} {{range $d.Categories}}
<option value="{{.Name}}">{{.Name}}</option> <option value="{{.Name}}">{{.Name}}</option>
{{end}} {{end}}
{{if not $d.Categories}} {{if not $d.Categories}}<option value="Others">Others</option>{{end}}
<option value="Others">Others</option>
{{end}}
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -132,20 +156,18 @@
{{end}} {{end}}
</select> </select>
</div> </div>
<div class="flex" style="gap:8px; justify-content:flex-end; margin-top:8px;">
<div id="add-error" class="error" style="display:none;"></div>
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:4px;">
<button class="btn btn-outline" onclick="closeAddModal()">Cancel</button> <button class="btn btn-outline" onclick="closeAddModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitAdd()">Save</button> <button class="btn btn-primary" onclick="submitAdd()">Save Transaction</button>
</div> </div>
<div id="add-error" class="error" style="display:none; margin-top:8px;"></div>
</div> </div>
</div> </div>
<style>
.desc-cell { max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cat-badge { cursor:default; display:inline-flex; align-items:center; }
</style>
<script> <script>
const catColors = {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}}; const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
function editCat(id) { function editCat(id) {
document.getElementById('cat-' + id).style.display = 'none'; document.getElementById('cat-' + id).style.display = 'none';
@ -154,15 +176,13 @@ function editCat(id) {
sel.style.display = 'inline-block'; sel.style.display = 'inline-block';
sel.focus(); sel.focus();
} }
function cancelEdit(id) { function cancelEdit(id) {
setTimeout(() => { setTimeout(() => {
document.getElementById('cat-' + id).style.display = 'inline-flex'; document.getElementById('cat-' + id).style.display = 'inline-flex';
document.getElementById('edit-btn-' + id).style.display = 'inline-block'; document.getElementById('edit-btn-' + id).style.display = 'inline-flex';
document.getElementById('sel-' + id).style.display = 'none'; document.getElementById('sel-' + id).style.display = 'none';
}, 150); }, 160);
} }
function saveCategory(id, cat) { function saveCategory(id, cat) {
fetch('/api/transactions/' + id, { fetch('/api/transactions/' + id, {
method: 'PUT', method: 'PUT',
@ -170,16 +190,17 @@ function saveCategory(id, cat) {
body: JSON.stringify({category: cat}) body: JSON.stringify({category: cat})
}).then(r => { }).then(r => {
if (!r.ok) return; if (!r.ok) return;
const badge = document.getElementById('cat-' + id);
const color = catColors[cat] || ''; const color = catColors[cat] || '';
badge.style.background = color ? color + '22' : '#e0e0e0'; const badge = document.getElementById('cat-' + id);
badge.style.color = color || '#555'; badge.style.background = color ? color + '18' : 'var(--bg3)';
badge.style.border = '1px solid ' + (color ? color + '44' : '#ccc'); badge.style.color = color || 'var(--text2)';
badge.innerHTML = (color ? `<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${color};margin-right:5px;vertical-align:middle;"></span>` : '') + cat; badge.style.borderColor = color ? color + '33' : 'var(--border2)';
badge.innerHTML = (color
? `<span class="category-dot" style="background:${color};box-shadow:0 0 4px ${color}88;"></span>`
: '') + cat;
cancelEdit(id); cancelEdit(id);
}); });
} }
function delTxn(id) { function delTxn(id) {
if (!confirm('Delete this transaction?')) return; if (!confirm('Delete this transaction?')) return;
fetch('/api/transactions/' + id, {method: 'DELETE'}).then(r => { fetch('/api/transactions/' + id, {method: 'DELETE'}).then(r => {
@ -188,47 +209,42 @@ function delTxn(id) {
} }
function openAddModal() { function openAddModal() {
const modal = document.getElementById('add-modal'); document.getElementById('add-modal').style.display = 'flex';
modal.style.display = 'flex';
document.getElementById('add-date').value = new Date().toISOString().slice(0, 10); document.getElementById('add-date').value = new Date().toISOString().slice(0, 10);
document.getElementById('add-desc').focus(); document.getElementById('add-desc').focus();
} }
function closeAddModal() { function closeAddModal() {
document.getElementById('add-modal').style.display = 'none'; document.getElementById('add-modal').style.display = 'none';
document.getElementById('add-error').style.display = 'none'; document.getElementById('add-error').style.display = 'none';
} }
function submitAdd() { function submitAdd() {
const date = document.getElementById('add-date').value; const date = document.getElementById('add-date').value;
const desc = document.getElementById('add-desc').value.trim(); const desc = document.getElementById('add-desc').value.trim();
const sign = parseInt(document.getElementById('add-sign').value); const sign = parseInt(document.getElementById('add-sign').value);
const amt = parseFloat(document.getElementById('add-amount').value); const amt = parseFloat(document.getElementById('add-amount').value);
const cat = document.getElementById('add-cat').value; const cat = document.getElementById('add-cat').value;
const account = document.getElementById('add-account').value; const acct = document.getElementById('add-account').value;
const errEl = document.getElementById('add-error');
if (!date || !desc || isNaN(amt) || amt <= 0) { if (!date || !desc || isNaN(amt) || amt <= 0) {
const err = document.getElementById('add-error'); errEl.textContent = 'Please fill in date, description, and a positive amount.';
err.textContent = 'Please fill in date, description, and a positive amount.'; errEl.style.display = 'block';
err.style.display = 'block';
return; return;
} }
errEl.style.display = 'none';
const amountCents = Math.round(amt * 100) * sign;
fetch('/api/transactions', { fetch('/api/transactions', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({account_id: account, date, description: desc, amount_cents: amountCents, category: cat}) body: JSON.stringify({account_id: acct, date, description: desc, amount_cents: Math.round(amt * 100) * sign, category: cat})
}).then(r => { }).then(r => {
if (!r.ok) { document.getElementById('add-error').textContent = 'Failed to save.'; document.getElementById('add-error').style.display = 'block'; return; } if (!r.ok) { errEl.textContent = 'Failed to save.'; errEl.style.display = 'block'; return; }
closeAddModal(); closeAddModal();
location.reload(); location.reload();
}); });
} }
document.getElementById('add-modal').addEventListener('click', function(e) { document.getElementById('add-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddModal(); });
if (e.target === this) closeAddModal();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); });
</script> </script>
{{end}} {{end}}