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{
|
render(w, dashboardTmpl, &DashboardData{
|
||||||
UserID: a.UserID,
|
UserID: a.UserID,
|
||||||
Email: a.Email,
|
Email: a.Email,
|
||||||
@ -467,6 +542,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
PortfolioHoldings: portfolioHoldings,
|
PortfolioHoldings: portfolioHoldings,
|
||||||
PortfolioPricesAvailable: portfolioPricesAvailable,
|
PortfolioPricesAvailable: portfolioPricesAvailable,
|
||||||
NetWorthCents: portfolioValueCents + running,
|
NetWorthCents: portfolioValueCents + running,
|
||||||
|
Alerts: alerts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -154,6 +154,8 @@ type DashboardData struct {
|
|||||||
PortfolioPricesAvailable bool
|
PortfolioPricesAvailable bool
|
||||||
|
|
||||||
NetWorthCents int64
|
NetWorthCents int64
|
||||||
|
|
||||||
|
Alerts []Alert
|
||||||
}
|
}
|
||||||
|
|
||||||
type PeriodSummary struct {
|
type PeriodSummary struct {
|
||||||
@ -219,6 +221,19 @@ type SharingUser struct {
|
|||||||
Email string
|
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 {
|
type SimulatorGoal struct {
|
||||||
Name string
|
Name string
|
||||||
MonthlyCents int64
|
MonthlyCents int64
|
||||||
|
|||||||
@ -6,6 +6,20 @@
|
|||||||
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
|
||||||
</div>
|
</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 -->
|
<!-- HERO: available to spend -->
|
||||||
<div class="card animate-on-scroll" style="margin-bottom:16px; padding:24px 28px;">
|
<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;">
|
<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