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:
parent
2ab3acdce2
commit
b4b7a1381c
@ -5,6 +5,9 @@ metadata:
|
||||
namespace: finance
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- finance.homelab.local
|
||||
rules:
|
||||
- host: finance.homelab.local
|
||||
http:
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ func main() {
|
||||
store.ensureAuthIndexes(ctx)
|
||||
|
||||
go SeedAdmin(ctx, store)
|
||||
go SeedExtras(ctx, store)
|
||||
|
||||
secret := os.Getenv("SESSION_SECRET")
|
||||
if secret == "" {
|
||||
|
||||
@ -158,6 +158,7 @@ type DashboardData struct {
|
||||
NetWorthCents int64
|
||||
|
||||
Alerts []Alert
|
||||
DashGoals []GoalPlan // committed goals for the dashboard widget
|
||||
}
|
||||
|
||||
type PeriodSummary struct {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -246,6 +246,40 @@
|
||||
|
||||
</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}}
|
||||
<div class="card animate-on-scroll" style="margin-top:16px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user