feat(dashboard): committed goals widget (#32)

* feat(dashboard): committed goals widget

Shows all committed goals on the dashboard with progress bars,
months remaining, saved vs target, and monthly required (green
when on track, red when not). Links to /goals for the full view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): enable TLS on ingress so Secure session cookie is honoured

BASE_URL was https:// but the ingress had no TLS block, causing the
browser to silently drop the Secure cookie after login. Adding tls: to
the Traefik ingress makes the site serve HTTPS via Traefik's default
cert so cookie and scheme match.

Also adds SeedExtras to seed goals and property/loan data independently
of the transaction-based idempotency guard in SeedAdmin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-16 22:27:57 +01:00 committed by GitHub
parent 2ab3acdce2
commit b4b7a1381c
6 changed files with 208 additions and 1 deletions

View File

@ -5,6 +5,9 @@ metadata:
namespace: finance namespace: finance
spec: spec:
ingressClassName: traefik ingressClassName: traefik
tls:
- hosts:
- finance.homelab.local
rules: rules:
- host: finance.homelab.local - host: finance.homelab.local
http: http:

View File

@ -705,6 +705,44 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
} }
} }
// ── Committed goals for dashboard widget ─────────────────────────────
var dashGoals []GoalPlan
if allGoals, err2 := h.store.getGoals(ctx, a.UserID); err2 == nil {
for _, g := range allGoals {
if !g.Committed {
continue
}
remaining := g.TargetCents - g.SavedCents
if remaining < 0 {
remaining = 0
}
ml := int64(monthsBetween(now, g.Deadline))
if ml < 1 {
ml = 1
}
monthly := remaining / ml
var atRate int64
if avgSavingsForGoals := disposableIncome; avgSavingsForGoals > 0 {
atRate = remaining / avgSavingsForGoals
}
pct := int64(0)
if g.TargetCents > 0 {
pct = g.SavedCents * 100 / g.TargetCents
if pct > 100 {
pct = 100
}
}
dashGoals = append(dashGoals, GoalPlan{
Goal: g,
MonthsLeft: ml,
MonthlyCents: monthly,
MonthsAtCurrentRate: atRate,
Feasible: disposableIncome >= monthly,
ProgressPct: pct,
})
}
}
// ── Alerts ────────────────────────────────────────────────────────── // ── Alerts ──────────────────────────────────────────────────────────
var alerts []Alert var alerts []Alert
@ -810,6 +848,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
PortfolioPricesAvailable: portfolioPricesAvailable, PortfolioPricesAvailable: portfolioPricesAvailable,
NetWorthCents: portfolioValueCents + running + dashPropertyEquity, NetWorthCents: portfolioValueCents + running + dashPropertyEquity,
Alerts: alerts, Alerts: alerts,
DashGoals: dashGoals,
}) })
} }

View File

@ -35,6 +35,7 @@ func main() {
store.ensureAuthIndexes(ctx) store.ensureAuthIndexes(ctx)
go SeedAdmin(ctx, store) go SeedAdmin(ctx, store)
go SeedExtras(ctx, store)
secret := os.Getenv("SESSION_SECRET") secret := os.Getenv("SESSION_SECRET")
if secret == "" { if secret == "" {

View File

@ -157,7 +157,8 @@ type DashboardData struct {
NetWorthCents int64 NetWorthCents int64
Alerts []Alert Alerts []Alert
DashGoals []GoalPlan // committed goals for the dashboard widget
} }
type PeriodSummary struct { type PeriodSummary struct {

View File

@ -63,6 +63,135 @@ func lookupUserByEmailMongo(ctx context.Context, store *Store, email string) (st
return legacy.ID, nil return legacy.ID, nil
} }
// SeedExtras seeds goals, property, and loan data if not already present.
// Called independently so it runs even when transactions already exist.
func SeedExtras(ctx context.Context, store *Store) {
email := os.Getenv("SEED_USER_EMAIL")
if email == "" {
email = "admin@homelab.local"
}
userID, err := lookupUserByEmailMongo(ctx, store, email)
if err != nil {
slog.Warn("seed extras: could not resolve user, skipping", "email", email, "err", err)
return
}
slog.Info("seed extras: checking for goals/property", "user_id", userID)
// goals
goals, _ := store.getGoals(ctx, userID)
if len(goals) == 0 {
slog.Info("seed: seeding goals", "user_id", userID)
if err := seedGoals(ctx, store, userID); err != nil {
slog.Error("seed: goals failed", "err", err)
}
}
// properties & loans
props, _ := store.getProperties(ctx, userID)
if len(props) == 0 {
slog.Info("seed: seeding property + loan", "user_id", userID)
if err := seedProperty(ctx, store, userID); err != nil {
slog.Error("seed: property failed", "err", err)
}
}
}
func seedGoals(ctx context.Context, store *Store, userID string) error {
now := time.Now()
goals := []*Goal{
{
ID: bson.NewObjectID().Hex(),
UserID: userID,
Name: "Emergency fund (3 months)",
Type: GoalTypeEmergency,
TargetCents: 390000, // €3,900
SavedCents: 210000, // €2,100 already set aside
Deadline: now.AddDate(1, 0, 0),
Committed: true,
CreatedAt: now.AddDate(0, -4, 0),
},
{
ID: bson.NewObjectID().Hex(),
UserID: userID,
Name: "Japan trip",
Type: GoalTypeOnce,
TargetCents: 350000, // €3,500
SavedCents: 80000, // €800 saved
Deadline: now.AddDate(1, 6, 0),
Committed: false,
CreatedAt: now.AddDate(0, -2, 0),
},
{
ID: bson.NewObjectID().Hex(),
UserID: userID,
Name: "MacBook Pro",
Type: GoalTypeOnce,
TargetCents: 250000, // €2,500
SavedCents: 40000, // €400
Deadline: now.AddDate(0, 10, 0),
Committed: false,
CreatedAt: now.AddDate(0, -1, 0),
},
{
ID: bson.NewObjectID().Hex(),
UserID: userID,
Name: "House down payment",
Type: GoalTypeDeposit,
TargetCents: 4000000, // €40,000
SavedCents: 500000, // €5,000 saved
Deadline: now.AddDate(5, 0, 0),
Committed: true,
CreatedAt: now.AddDate(0, -6, 0),
},
}
for _, g := range goals {
if err := store.createGoal(ctx, g); err != nil {
return fmt.Errorf("create goal %q: %w", g.Name, err)
}
}
return nil
}
func seedProperty(ctx context.Context, store *Store, userID string) error {
now := time.Now()
propID := bson.NewObjectID().Hex()
loanID := bson.NewObjectID().Hex()
prop := &Property{
ID: propID,
UserID: userID,
Name: "Apartamento T2 — Porto",
Address: "Rua de Santa Catarina, Porto",
PurchasePriceCents: 18000000, // €180,000
CurrentValueCents: 22000000, // €220,000
AppreciationPct: 3.0,
PurchaseDate: now.AddDate(-4, 0, 0),
Status: PropertyOwned,
CreatedAt: now.AddDate(-4, 0, 0),
}
if err := store.createProperty(ctx, prop); err != nil {
return fmt.Errorf("create property: %w", err)
}
// 25-year mortgage, started 4 years ago, ~€900/month at 3.5%
loan := &Loan{
ID: loanID,
UserID: userID,
PropertyID: propID,
Name: "Hipoteca CGD — Porto",
Type: LoanMortgage,
PrincipalCents: 18000000,
BalanceCents: 16068700, // after 48 payments
InterestRatePct: 3.5,
TermMonths: 300, // 25 years
StartDate: now.AddDate(-4, 0, 0),
MonthlyPaymentCents: 90070, // €900.70 EMI
Status: LoanActive,
CreatedAt: now.AddDate(-4, 0, 0),
}
return store.createLoan(ctx, loan)
}
func seedAll(ctx context.Context, store *Store, userID string) error { func seedAll(ctx context.Context, store *Store, userID string) error {
// ── Accounts ───────────────────────────────────────────────────────── // ── Accounts ─────────────────────────────────────────────────────────
checkingID := bson.NewObjectID().Hex() checkingID := bson.NewObjectID().Hex()

View File

@ -246,6 +246,40 @@
</div> </div>
{{if $d.DashGoals}}
<div class="card animate-on-scroll" style="margin-top:16px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2>Committed goals</h2>
<a href="/goals" style="font-size:12px; color:var(--text3);">→ all goals</a>
</div>
<div style="display:flex; flex-direction:column; gap:14px;">
{{range $d.DashGoals}}
<div>
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:6px;">
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:15px;">{{if eq .Type "once"}}🎯{{else if eq .Type "deposit"}}🏠{{else if eq .Type "emergency"}}🛡️{{else}}📈{{end}}</span>
<span style="font-size:13px; font-weight:500; color:var(--text);">{{.Name}}</span>
</div>
<div style="display:flex; align-items:center; gap:12px;">
<span style="font-size:12px; color:var(--text3);">{{.MonthsLeft}}mo left</span>
<span style="font-size:12px; font-weight:600; color:{{if .Feasible}}var(--green){{else}}var(--red){{end}};">{{.ProgressPct}}%</span>
</div>
</div>
<div style="background:var(--bg3); border-radius:99px; height:5px; overflow:hidden;">
<div style="height:100%; border-radius:99px; width:{{.ProgressPct}}%;
background:{{if .Feasible}}var(--green){{else}}var(--accent){{end}};
transition:width 1s ease;"></div>
</div>
<div style="display:flex; justify-content:space-between; margin-top:4px; font-size:11px; color:var(--text3);">
<span>€{{cents .SavedCents}} of €{{cents .TargetCents}}</span>
<span style="color:{{if .Feasible}}var(--green){{else}}var(--red){{end}};">€{{cents .MonthlyCents}}/mo needed</span>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{if $d.RecurringExpenses}} {{if $d.RecurringExpenses}}
<div class="card animate-on-scroll" style="margin-top:16px;"> <div class="card animate-on-scroll" style="margin-top:16px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">