From b4b7a1381cd61a4ad5b2b373d1775bd15aae4944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= <95761178+GoncaloRodri@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:27:57 +0100 Subject: [PATCH] feat(dashboard): committed goals widget (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Gonçalo Rodrigues Co-authored-by: Claude Sonnet 4.6 --- apps/finance/services/api/k8s/ingress.yaml | 3 + apps/finance/services/api/main/handler.go | 39 ++++++ apps/finance/services/api/main/main.go | 1 + apps/finance/services/api/main/models.go | 3 +- apps/finance/services/api/main/seed.go | 129 ++++++++++++++++++ .../api/main/templates/dashboard.html | 34 +++++ 6 files changed, 208 insertions(+), 1 deletion(-) diff --git a/apps/finance/services/api/k8s/ingress.yaml b/apps/finance/services/api/k8s/ingress.yaml index 8d37aad..2afac33 100644 --- a/apps/finance/services/api/k8s/ingress.yaml +++ b/apps/finance/services/api/k8s/ingress.yaml @@ -5,6 +5,9 @@ metadata: namespace: finance spec: ingressClassName: traefik + tls: + - hosts: + - finance.homelab.local rules: - host: finance.homelab.local http: diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 146a677..d10217f 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -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 ────────────────────────────────────────────────────────── var alerts []Alert @@ -810,6 +848,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { PortfolioPricesAvailable: portfolioPricesAvailable, NetWorthCents: portfolioValueCents + running + dashPropertyEquity, Alerts: alerts, + DashGoals: dashGoals, }) } diff --git a/apps/finance/services/api/main/main.go b/apps/finance/services/api/main/main.go index a4cad70..9956144 100644 --- a/apps/finance/services/api/main/main.go +++ b/apps/finance/services/api/main/main.go @@ -35,6 +35,7 @@ func main() { store.ensureAuthIndexes(ctx) go SeedAdmin(ctx, store) + go SeedExtras(ctx, store) secret := os.Getenv("SESSION_SECRET") if secret == "" { diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index 2562035..541ef8a 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -157,7 +157,8 @@ type DashboardData struct { NetWorthCents int64 - Alerts []Alert + Alerts []Alert + DashGoals []GoalPlan // committed goals for the dashboard widget } type PeriodSummary struct { diff --git a/apps/finance/services/api/main/seed.go b/apps/finance/services/api/main/seed.go index fd77c6f..f54769e 100644 --- a/apps/finance/services/api/main/seed.go +++ b/apps/finance/services/api/main/seed.go @@ -63,6 +63,135 @@ func lookupUserByEmailMongo(ctx context.Context, store *Store, email string) (st 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 { // ── Accounts ───────────────────────────────────────────────────────── checkingID := bson.NewObjectID().Hex() diff --git a/apps/finance/services/api/main/templates/dashboard.html b/apps/finance/services/api/main/templates/dashboard.html index d4773d3..6277332 100644 --- a/apps/finance/services/api/main/templates/dashboard.html +++ b/apps/finance/services/api/main/templates/dashboard.html @@ -246,6 +246,40 @@ +{{if $d.DashGoals}} +
+
+

Committed goals

+ → all goals +
+
+ {{range $d.DashGoals}} +
+
+
+ {{if eq .Type "once"}}🎯{{else if eq .Type "deposit"}}🏠{{else if eq .Type "emergency"}}🛡️{{else}}📈{{end}} + {{.Name}} +
+
+ {{.MonthsLeft}}mo left + {{.ProgressPct}}% +
+
+
+
+
+
+ €{{cents .SavedCents}} of €{{cents .TargetCents}} + €{{cents .MonthlyCents}}/mo needed +
+
+ {{end}} +
+
+{{end}} + {{if $d.RecurringExpenses}}