From 1d3aa764cb97eaf985c7314da0a89694ff218e20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 15:51:46 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20phase=201=20dashboard=20redesign=20?= =?UTF-8?q?=E2=80=94=20disposable=20income=20+=20smart=20panels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old KPI/chart dashboard with a focused layout that answers the three key questions immediately: - Hero block: "Available to spend" = income − fixed costs − spent so far, with a progress bar showing % of disposable used vs month elapsed - Bank math panel: detects recurring fixed expenses (Housing, Utilities, Subscriptions, Investments) from last 3 months and shows the minimum bank balance needed right now including a 2-week safety buffer - Savings rate card with month-over-month delta - Portfolio snapshot card with total value and P&L - Stocks at a glance panel: per-holding value and P&L inline - Budget health: thin bars per category, red when over limit - Recent activity: last 5 transactions with category color dots Co-Authored-By: Claude Sonnet 4.6 --- .claude/launch.json | 11 + apps/finance/services/api/main/handler.go | 204 +++++++-- apps/finance/services/api/main/models.go | 35 ++ .../api/main/templates/dashboard.html | 389 +++++++++--------- 4 files changed, 404 insertions(+), 235 deletions(-) create mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..c7e9b83 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "finance-api", + "runtimeExecutable": "go", + "runtimeArgs": ["run", "./apps/finance/services/api/main/"], + "port": 8080 + } + ] +} diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 01e1704..5fc7281 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -204,7 +204,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { now := time.Now() thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) lastStart := thisStart.AddDate(0, -1, 0) - lastEnd := thisStart.Add(-time.Nanosecond) + threeMonthsAgo := thisStart.AddDate(0, -3, 0) txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{}) if err != nil { @@ -217,21 +217,22 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { if err != nil { slog.Error("get categories", "err", err) } + catColors := make(map[string]string) + catBudgets := make(map[string]int64) catNames := make(map[string]string) for _, c := range cats { catNames[c.Name] = c.Name + catColors[c.Name] = c.Color + if c.BudgetCents > 0 { + catBudgets[c.Name] = c.BudgetCents + } } - thisMonth := &PeriodSummary{ - TotalCents: 0, - ByCategory: make(map[string]int64), - CategoryNames: catNames, - } - lastMonth := &PeriodSummary{ - TotalCents: 0, - ByCategory: make(map[string]int64), - CategoryNames: catNames, - } + thisMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames} + lastMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames} + + // fixed spending by category over the last 3 months (for recurring detection) + fixedByMonth := make(map[string]map[int]int64) // category -> month-offset -> total var recent []Transaction var balPoints []BalancePoint @@ -239,25 +240,36 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { var balDates []string for _, t := range txns { - if t.Date.After(thisStart) || t.Date.Equal(thisStart) { + isThisMonth := !t.Date.Before(thisStart) + isLastMonth := !t.Date.Before(lastStart) && t.Date.Before(thisStart) + isRecent3 := !t.Date.Before(threeMonthsAgo) && t.Date.Before(thisStart) + + if isThisMonth { thisMonth.TotalCents += t.AmountCents thisMonth.ByCategory[t.Category] += t.AmountCents - } else if t.Date.After(lastStart) && t.Date.Before(lastEnd.Add(24*time.Hour)) { + } else if isLastMonth { lastMonth.TotalCents += t.AmountCents lastMonth.ByCategory[t.Category] += t.AmountCents } - if len(recent) < 10 { + // accumulate fixed category spending over last 3 months + if isRecent3 && FixedCategories[t.Category] && t.AmountCents < 0 { + mo := int(t.Date.Month()) + if fixedByMonth[t.Category] == nil { + fixedByMonth[t.Category] = make(map[int]int64) + } + fixedByMonth[t.Category][mo] += -t.AmountCents + } + + if len(recent) < 5 { recent = append(recent, t) } day := t.Date.Format("2006-01-02") balByDate[day] += t.AmountCents + balDates = appendIfMissing(balDates, day) } - for d := range balByDate { - balDates = append(balDates, d) - } sortStrings(balDates) running := int64(0) for _, d := range balDates { @@ -269,41 +281,142 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { balPoints = balPoints[len(balPoints)-90:] } - // compute income vs expense split for this month + // income / expense split thisMonthIncome := int64(0) thisMonthExpense := int64(0) for _, amt := range thisMonth.ByCategory { if amt > 0 { thisMonthIncome += amt } else { - thisMonthExpense += amt + thisMonthExpense += -amt + } + } + lastMonthIncome := int64(0) + lastMonthSavings := int64(0) + for _, amt := range lastMonth.ByCategory { + if amt > 0 { + lastMonthIncome += amt + } + } + lastMonthSavings = lastMonth.TotalCents + if lastMonthSavings < 0 { + lastMonthSavings = 0 + } + + // detect recurring fixed expenses (average over last 3 months) + var recurringExpenses []RecurringExpense + totalFixedCents := int64(0) + for cat, byMonth := range fixedByMonth { + total := int64(0) + for _, v := range byMonth { + total += v + } + avg := total / int64(len(byMonth)) + recurringExpenses = append(recurringExpenses, RecurringExpense{Category: cat, MonthlyCents: avg}) + totalFixedCents += avg + } + sort.Slice(recurringExpenses, func(i, j int) bool { + return recurringExpenses[i].MonthlyCents > recurringExpenses[j].MonthlyCents + }) + + // disposable income = income - fixed recurring + disposableIncome := thisMonthIncome - totalFixedCents + + // variable spend so far this month (non-fixed categories, expenses only) + variableSpent := int64(0) + for cat, amt := range thisMonth.ByCategory { + if !FixedCategories[cat] && amt < 0 { + variableSpent += -amt } } - // budget data: map category name -> budget cents - catBudgets := make(map[string]int64) - catColors := make(map[string]string) - for _, c := range cats { - if c.BudgetCents > 0 { - catBudgets[c.Name] = c.BudgetCents - } - catColors[c.Name] = c.Color + availableToSpend := disposableIncome - variableSpent + if availableToSpend < 0 { + availableToSpend = 0 } - render(w, dashboardTmpl, map[string]interface{}{ - "UserID": a.UserID, - "Email": a.Email, - "Title": "Dashboard", - "Route": "dashboard", - "IsOwner": true, - "ThisMonth": thisMonth, - "LastMonth": lastMonth, - "RecentTxns": recent, - "BalanceTrend": balPoints, - "ThisMonthIncome": thisMonthIncome, - "ThisMonthExpense": thisMonthExpense, - "CategoryBudgets": catBudgets, - "CategoryColors": catColors, + // month progress + daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day() + monthProgressPct := int(float64(now.Day()) / float64(daysInMonth) * 100) + + // % of disposable already spent + monthSpentPct := 0 + if disposableIncome > 0 { + monthSpentPct = int(float64(variableSpent) / float64(disposableIncome) * 100) + if monthSpentPct > 100 { + monthSpentPct = 100 + } + } + + // safety buffer = 2 weeks of average daily variable spend over last month + lastMonthVariableSpent := int64(0) + for cat, amt := range lastMonth.ByCategory { + if !FixedCategories[cat] && amt < 0 { + lastMonthVariableSpent += -amt + } + } + safetyBuffer := lastMonthVariableSpent / 2 + + // bank should be = upcoming fixed costs (not yet paid this month) + safety buffer + fixedPaidThisMonth := int64(0) + for cat, amt := range thisMonth.ByCategory { + if FixedCategories[cat] && amt < 0 { + fixedPaidThisMonth += -amt + } + } + bankShouldBe := (totalFixedCents - fixedPaidThisMonth) + safetyBuffer + + // savings rate + savingsRatePct := 0 + if thisMonthIncome > 0 { + saved := thisMonthIncome - thisMonthExpense + if saved > 0 { + savingsRatePct = int(float64(saved) / float64(thisMonthIncome) * 100) + } + } + lastMonthSavingsRatePct := 0 + if lastMonthIncome > 0 && lastMonthSavings > 0 { + lastMonthSavingsRatePct = int(float64(lastMonthSavings) / float64(lastMonthIncome) * 100) + } + + // portfolio snapshot (best-effort, ignore errors) + var portfolioValueCents, portfolioPCLCents int64 + var portfolioHoldings []Holding + if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 { + if prices, err := fetchPricesByISIN(uniqueISINs(trades)); err == nil { + pr := aggregatePortfolio(computeHoldings(trades, prices)) + portfolioValueCents = pr.TotalVal + portfolioPCLCents = pr.TotalPCL + portfolioHoldings = pr.Holdings + } + } + + render(w, dashboardTmpl, &DashboardData{ + UserID: a.UserID, + Email: a.Email, + Title: "Dashboard", + Route: "dashboard", + IsOwner: true, + ThisMonth: thisMonth, + LastMonth: lastMonth, + RecentTxns: recent, + BalanceTrend: balPoints, + ThisMonthIncome: thisMonthIncome, + ThisMonthExpense: thisMonthExpense, + CategoryBudgets: catBudgets, + CategoryColors: catColors, + AvailableToSpend: availableToSpend, + DisposableIncome: disposableIncome, + MonthProgressPct: monthProgressPct, + MonthSpentPct: monthSpentPct, + RecurringExpenses: recurringExpenses, + BankShouldBe: bankShouldBe, + SafetyBufferCents: safetyBuffer, + SavingsRatePct: savingsRatePct, + LastMonthSavingsRatePct: lastMonthSavingsRatePct, + PortfolioValueCents: portfolioValueCents, + PortfolioPCLCents: portfolioPCLCents, + PortfolioHoldings: portfolioHoldings, }) } @@ -1227,3 +1340,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { func sortStrings(s []string) { sort.Strings(s) } + +func appendIfMissing(s []string, v string) []string { + for _, x := range s { + if x == v { + return s + } + } + return append(s, v) +} diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index 08a7a47..49a7357 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -104,6 +104,19 @@ type CSVImportPreview struct { Total int `json:"total"` } +// FixedCategories are treated as recurring committed costs, not variable spend. +var FixedCategories = map[string]bool{ + "Housing": true, + "Utilities": true, + "Subscriptions": true, + "Investments": true, +} + +type RecurringExpense struct { + Category string + MonthlyCents int64 +} + type DashboardData struct { UserID string Email string @@ -114,6 +127,28 @@ type DashboardData struct { LastMonth *PeriodSummary RecentTxns []Transaction BalanceTrend []BalancePoint + + // Phase 1 fields + ThisMonthIncome int64 + ThisMonthExpense int64 + CategoryBudgets map[string]int64 + CategoryColors map[string]string + + AvailableToSpend int64 // income − fixed − variable budgets spent so far + DisposableIncome int64 // income − fixed recurring costs + MonthProgressPct int // % of month elapsed + MonthSpentPct int // % of disposable already spent + + RecurringExpenses []RecurringExpense + BankShouldBe int64 // sum of upcoming fixed costs + safety buffer + SafetyBufferCents int64 + + SavingsRatePct int // savings / income * 100 this month + LastMonthSavingsRatePct int + + PortfolioValueCents int64 + PortfolioPCLCents int64 + PortfolioHoldings []Holding } type PeriodSummary struct { diff --git a/apps/finance/services/api/main/templates/dashboard.html b/apps/finance/services/api/main/templates/dashboard.html index a9ec5ce..bb2c43f 100644 --- a/apps/finance/services/api/main/templates/dashboard.html +++ b/apps/finance/services/api/main/templates/dashboard.html @@ -1,217 +1,218 @@ {{define "content"}} {{$d := .}} -{{$change := sub $d.ThisMonth.TotalCents $d.LastMonth.TotalCents}}

Dashboard

{{if $d.Email}}{{$d.Email}}{{end}}
- -
-
-

Net This Month

-
€0.00
+ +
+
+ Available to spend this month + + income − fixed costs − spent so far +
-
-

Income

-
€0.00
+ +
+
€0.00
+
+ of €0.00 disposable +  ·  {{$d.MonthSpentPct}}% used +
-
-

Expenses

-
€0.00
+ +
+
-
-

vs Last Month

-
€0.00
+
+ Month progress: {{$d.MonthProgressPct}}% + Spent: {{$d.MonthSpentPct}}%
- + +
+ +
+

Bank balance should be

+
€0.00
+

upcoming fixed + safety buffer

+
+ +
+

Savings rate

+
{{$d.SavingsRatePct}}%
+ {{if $d.LastMonthSavingsRatePct}} +

+ {{if gt $d.SavingsRatePct $d.LastMonthSavingsRatePct}}↑{{else}}↓{{end}} vs last month ({{$d.LastMonthSavingsRatePct}}%) +

+ {{end}} +
+ +
+

Portfolio today

+ {{if $d.PortfolioValueCents}} +
€0.00
+

+ {{if ge $d.PortfolioPCLCents 0}}+{{else}}−{{end}}€{{cents (centsAbs $d.PortfolioPCLCents)}} total P&L +

+ {{else}} +
No trades yet
+

Import trades →

+ {{end}} +
+ +
+ + +
+ +
+
+

What should be in your bank

+ right now +
+ {{if $d.RecurringExpenses}} +
+ {{range $d.RecurringExpenses}} + {{$color := index $d.CategoryColors .Category}} +
+ + {{if $color}}{{end}} + {{.Category}} + + − €{{cents .MonthlyCents}} +
+ {{end}} + {{if $d.SafetyBufferCents}} +
+ Safety buffer (2 weeks) + − €{{cents $d.SafetyBufferCents}} +
+ {{end}} +
+ Minimum recommended + €0.00 +
+
+ {{else}} +
+

No recurring expenses detected yet.
Import a few months of transactions.

+
+ {{end}} +
+ +
+
+

Stocks at a glance

+ → portfolio +
+ {{if $d.PortfolioHoldings}} +
+ {{range $d.PortfolioHoldings}} +
+
+
{{.Name}}
+
{{.ISIN}} · {{printf "%.2f" .SharesOwned}} shares
+
+
+
€{{cents .CurrentValueCents}}
+
+ {{pctSign .UnrealizedPCLPct}}{{printf "%.1f" .UnrealizedPCLPct}}% +
+
+
+ {{end}} +
+ Total + €0.00 +
+
+ {{else}} +
+

No holdings yet.
Import trades →

+
+ {{end}} +
+ +
+ +
+ + {{if $d.CategoryBudgets}}
-

Spending by Category — This Month

- {{if $d.ThisMonth.ByCategory}} -
- +
+

Budget health

+ → categories +
+
+ {{range $cat, $budget := $d.CategoryBudgets}} + {{$spent := index $d.ThisMonth.ByCategory $cat}} + {{$spentAbs := centsAbs $spent}} + {{$color := index $d.CategoryColors $cat}} + {{$over := isOver $spentAbs $budget}} + {{$pct := clampPct $spentAbs $budget}} +
+
+ + {{if $color}}{{end}} + {{$cat}} + + + {{$pct}}%{{if $over}} ⚠{{end}} + +
+
+
+
+
+ {{end}} +
+
+ {{end}} + +
+
+

Recent activity

+ → all transactions +
+ {{if $d.RecentTxns}} +
+ {{range $d.RecentTxns}} + {{$color := index $d.CategoryColors .Category}} +
+
+
+
+
{{.Description}}
+
{{dateShort .Date}}
+
+
+
+ {{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}} +
+
+ {{end}}
{{else}} -
No spending data this month.
- {{end}} -
-
-

Balance Trend — 90 Days

- {{if $d.BalanceTrend}} -
- -
- {{else}} -
No transactions yet. Import some!
- {{end}} -
-
- - -{{if $d.CategoryBudgets}} -
-
-

Budget vs Actual — This Month

- Manage budgets -
-
- {{range $cat, $budget := $d.CategoryBudgets}} - {{$spent := index $d.ThisMonth.ByCategory $cat}} - {{$spentAbs := centsAbs $spent}} - {{$color := index $d.CategoryColors $cat}} - {{$over := isOver $spentAbs $budget}} - {{$pct := clampPct $spentAbs $budget}} -
-
- - {{if $color}}{{end}} - {{$cat}} - - - €{{cents $spentAbs}} / €{{cents $budget}} - {{if $over}} ⚠ over budget{{end}} - -
-
-
-
+
+ No transactions yet. Import some!
{{end}}
+
{{end}} - - -
-
-

Recent Transactions

- View all -
-
- - - - - - - - - - - {{range $d.RecentTxns}} - {{$color := index $d.CategoryColors .Category}} - - - - - - - {{else}} - - - - {{end}} - -
DateDescriptionCategoryAmount
{{dateShort .Date}}{{.Description}} - - {{if $color}}{{end}} - {{.Category}} - - - {{if lt .AmountCents 0}}−{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}} -
- No transactions yet. Import some! -
-
-
- - -{{end}}