feat: phase 6 — dashboard alerts and nudges
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 <noreply@anthropic.com>
This commit is contained in:
parent
39282ff550
commit
995c6d89d6
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -6,6 +6,20 @@
|
||||
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
||||
</div>
|
||||
|
||||
{{if $d.Alerts}}
|
||||
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:16px;">
|
||||
{{range $d.Alerts}}
|
||||
<div style="display:flex; align-items:flex-start; gap:10px; padding:12px 16px; border-radius:10px; font-size:13px;
|
||||
{{if eq .Level "danger"}}background:rgba(248,113,113,0.08); border:1px solid rgba(248,113,113,0.25); color:var(--red);
|
||||
{{else if eq .Level "warn"}}background:rgba(245,158,11,0.08); border:1px solid rgba(245,158,11,0.25); color:#f59e0b;
|
||||
{{else}}background:rgba(105,121,248,0.08); border:1px solid rgba(105,121,248,0.25); color:var(--accent);{{end}}">
|
||||
<span style="flex-shrink:0; font-size:15px;">{{if eq .Level "danger"}}🔴{{else if eq .Level "warn"}}⚠{{else}}ℹ{{end}}</span>
|
||||
<span>{{.Message}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- HERO: available to spend -->
|
||||
<div class="card animate-on-scroll" style="margin-bottom:16px; padding:24px 28px;">
|
||||
<div style="font-size:12px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:10px; display:flex; align-items:center; gap:8px;">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user