From 995c6d89d604dd97cc7ec79f5ed043eddcc99833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 17:03:12 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20phase=206=20=E2=80=94=20dashboard=20ale?= =?UTF-8?q?rts=20and=20nudges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds server-computed alert banners to the dashboard: - Budget exceeded (red): when a category spend is over 100% of budget - Budget pace warning (amber): 80%+ of budget used but <80% of month elapsed - Goal deadline risk (amber): avg savings rate too low to hit a goal on time - Spend pace warning (amber): disposable spending is 20%+ ahead of month progress Co-Authored-By: Claude Sonnet 4.6 --- apps/finance/services/api/main/handler.go | 76 +++++++++++++++++++ apps/finance/services/api/main/models.go | 15 ++++ .../api/main/templates/dashboard.html | 14 ++++ 3 files changed, 105 insertions(+) diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 42cadba..d60e0a0 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -438,6 +438,81 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { } } + // ── Alerts ────────────────────────────────────────────────────────── + var alerts []Alert + + // budget overspend alerts — compare per-category spend vs budget + for cat, budget := range catBudgets { + spent := -thisMonth.ByCategory[cat] // expenses are negative + if spent <= 0 || budget <= 0 { + continue + } + pct := int(float64(spent) / float64(budget) * 100) + if pct >= 100 { + alerts = append(alerts, Alert{ + Level: AlertDanger, + Message: fmt.Sprintf("You've exceeded your %s budget (€%.0f of €%.0f — %d%%).", cat, float64(spent)/100, float64(budget)/100, pct), + }) + } else if pct >= 80 && monthProgressPct < 80 { + alerts = append(alerts, Alert{ + Level: AlertWarn, + Message: fmt.Sprintf("You've used %d%% of your %s budget but only %d%% of the month has passed.", pct, cat, monthProgressPct), + }) + } + } + + // goal deadline risk alerts + if goalList, err := h.store.getGoals(ctx, a.UserID); err == nil { + threeAgo := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -3, 0) + moSavings := make(map[int]int64) + for _, t := range txns { + if !t.Date.Before(threeAgo) && t.Date.Before(thisStart) { + moSavings[int(t.Date.Month())] += t.AmountCents + } + } + var totalS int64 + for _, s := range moSavings { + if s > 0 { + totalS += s + } + } + avgS := int64(0) + if len(moSavings) > 0 { + avgS = totalS / int64(len(moSavings)) + } + for _, g := range goalList { + remaining := g.TargetCents - g.SavedCents + if remaining <= 0 { + continue + } + ml := int64(monthsBetween(now, g.Deadline)) + if ml < 1 { + ml = 1 + } + needed := remaining / ml + if avgS < needed { + monthsOff := int64(0) + if avgS > 0 { + monthsOff = remaining/avgS - ml + } + msg := fmt.Sprintf("You're on track to miss your \"%s\" goal", g.Name) + if monthsOff > 0 { + msg += fmt.Sprintf(" by %d month(s)", monthsOff) + } + msg += fmt.Sprintf(" — need €%.0f/mo but saving ~€%.0f/mo.", float64(needed)/100, float64(avgS)/100) + alerts = append(alerts, Alert{Level: AlertWarn, Message: msg}) + } + } + } + + // overall spend pace alert + if monthProgressPct > 0 && monthSpentPct > monthProgressPct+20 { + alerts = append(alerts, Alert{ + Level: AlertWarn, + Message: fmt.Sprintf("You've spent %d%% of your disposable income but only %d%% of the month has passed — you're ahead of pace.", monthSpentPct, monthProgressPct), + }) + } + render(w, dashboardTmpl, &DashboardData{ UserID: a.UserID, Email: a.Email, @@ -467,6 +542,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { PortfolioHoldings: portfolioHoldings, PortfolioPricesAvailable: portfolioPricesAvailable, NetWorthCents: portfolioValueCents + running, + Alerts: alerts, }) } diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index d0af5d9..ec9907e 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -154,6 +154,8 @@ type DashboardData struct { PortfolioPricesAvailable bool NetWorthCents int64 + + Alerts []Alert } type PeriodSummary struct { @@ -219,6 +221,19 @@ type SharingUser struct { Email string } +type AlertLevel string + +const ( + AlertWarn AlertLevel = "warn" + AlertDanger AlertLevel = "danger" + AlertInfo AlertLevel = "info" +) + +type Alert struct { + Level AlertLevel + Message string +} + type SimulatorGoal struct { Name string MonthlyCents int64 diff --git a/apps/finance/services/api/main/templates/dashboard.html b/apps/finance/services/api/main/templates/dashboard.html index 06cf22d..d4773d3 100644 --- a/apps/finance/services/api/main/templates/dashboard.html +++ b/apps/finance/services/api/main/templates/dashboard.html @@ -6,6 +6,20 @@ {{if $d.Email}}{{$d.Email}}{{end}} +{{if $d.Alerts}} +
+ {{range $d.Alerts}} +
+ {{if eq .Level "danger"}}🔴{{else if eq .Level "warn"}}⚠{{else}}ℹ{{end}} + {{.Message}} +
+ {{end}} +
+{{end}} +