Merge pull request #12 from GoncaloRodri/feature/phase6-alerts

feat: phase 6 — dashboard alerts and nudges
This commit is contained in:
Gonçalo Rodrigues 2026-06-13 17:05:39 +01:00 committed by GitHub
commit dfe7d14475
3 changed files with 105 additions and 0 deletions

View File

@ -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,
}) })
} }

View File

@ -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

View File

@ -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;">