From 3b041267ad2e123870ee3012d066da556537ac36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 16:37:45 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20phase=203=20=E2=80=94=20goals=20com?= =?UTF-8?q?mit=20+=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Commit/uncommit button on each goal card - Committed goals are deducted from disposable income on the dashboard (Available to spend now reflects reserved goal contributions) - Conflict detection: warning banner when committed goals exceed disposable income, showing the shortfall - Goals summary bar: disposable before goals, reserved per month, free to spend after committed goals Co-Authored-By: Claude Sonnet 4.6 --- apps/finance/services/api/main/handler.go | 70 +++++++++++++++++-- apps/finance/services/api/main/models.go | 17 +++-- apps/finance/services/api/main/store.go | 7 ++ .../services/api/main/templates/goals.html | 51 +++++++++++--- 4 files changed, 123 insertions(+), 22 deletions(-) diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 548d7f0..b513101 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -324,6 +324,27 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { // disposable income = income - fixed recurring disposableIncome := thisMonthIncome - totalFixedCents + // deduct committed goal contributions from disposable + committedGoalsCents := int64(0) + if goals, err := h.store.getGoals(ctx, a.UserID); err == nil { + now2 := time.Now() + for _, g := range goals { + if !g.Committed { + continue + } + remaining := g.TargetCents - g.SavedCents + if remaining <= 0 { + continue + } + ml := int64(monthsBetween(now2, g.Deadline)) + if ml < 1 { + ml = 1 + } + committedGoalsCents += remaining / ml + } + } + disposableIncome -= committedGoalsCents + // variable spend so far this month (non-fixed categories, expenses only) variableSpent := int64(0) for cat, amt := range thisMonth.ByCategory { @@ -1347,6 +1368,13 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) { return } + if action == "commit" || action == "uncommit" { + id := r.FormValue("id") + h.store.updateGoal(ctx, id, a.UserID, bson.M{"committed": action == "commit"}) + http.Redirect(w, r, "/goals", http.StatusSeeOther) + return + } + // create goal name := r.FormValue("name") goalType := GoalType(r.FormValue("type")) @@ -1454,14 +1482,42 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) { }) } + // sum committed goal contributions and detect conflicts + committedTotal := int64(0) + for _, p := range plans { + if p.Committed { + committedTotal += p.MonthlyCents + } + } + remainingDisposable := disposable - committedTotal + + conflictWarning := "" + if committedTotal > disposable { + // find which committed goals are in conflict + var conflictNames []string + for _, p := range plans { + if p.Committed { + conflictNames = append(conflictNames, p.Name) + } + } + conflictWarning = fmt.Sprintf( + "Your committed goals require €%.0f/month but your disposable income is €%.0f/month. Consider pushing back a deadline or removing a goal.", + float64(committedTotal)/100, float64(disposable)/100, + ) + _ = conflictNames + } + render(w, goalsTmpl, &GoalsData{ - UserID: a.UserID, - Email: a.Email, - Title: "Goals", - Route: "goals", - Goals: plans, - AvgMonthlySavings: avgMonthlySavings, - DisposableIncome: disposable, + UserID: a.UserID, + Email: a.Email, + Title: "Goals", + Route: "goals", + Goals: plans, + AvgMonthlySavings: avgMonthlySavings, + DisposableIncome: disposable, + CommittedMonthlyCents: committedTotal, + RemainingDisposable: remainingDisposable, + ConflictWarning: conflictWarning, }) } diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index 7a01138..6000ee7 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -249,11 +249,14 @@ type GoalPlan struct { } type GoalsData struct { - UserID string - Email string - Title string - Route string - Goals []GoalPlan - AvgMonthlySavings int64 // 3-month average savings for projection - DisposableIncome int64 // from current month dashboard calc + UserID string + Email string + Title string + Route string + Goals []GoalPlan + AvgMonthlySavings int64 + DisposableIncome int64 + CommittedMonthlyCents int64 // sum of all committed goal contributions + RemainingDisposable int64 // disposable after committed goals + ConflictWarning string // set when committing would exceed disposable } diff --git a/apps/finance/services/api/main/store.go b/apps/finance/services/api/main/store.go index 729ad0e..3c0fc28 100644 --- a/apps/finance/services/api/main/store.go +++ b/apps/finance/services/api/main/store.go @@ -287,6 +287,13 @@ func (s *Store) createGoal(ctx context.Context, g *Goal) error { return err } +func (s *Store) updateGoal(ctx context.Context, id, userID string, update bson.M) error { + ctx, span := mongo.StartSpan(ctx, "Store.updateGoal") + defer span.End() + _, err := s.goals().UpdateOne(ctx, bson.M{"_id": id, "user_id": userID}, bson.M{"$set": update}) + return err +} + func (s *Store) deleteGoal(ctx context.Context, id, userID string) error { ctx, span := mongo.StartSpan(ctx, "Store.deleteGoal") defer span.End() diff --git a/apps/finance/services/api/main/templates/goals.html b/apps/finance/services/api/main/templates/goals.html index 9ee1452..329c3c7 100644 --- a/apps/finance/services/api/main/templates/goals.html +++ b/apps/finance/services/api/main/templates/goals.html @@ -7,6 +7,13 @@ class="btn btn-primary btn-sm">+ New goal +{{if $d.ConflictWarning}} +
+ ⚠ {{$d.ConflictWarning}} +
+{{end}} + {{if $d.AvgMonthlySavings}}
@@ -17,8 +24,21 @@

Disposable income

€0
-

this month

+

before goals

+ {{if $d.CommittedMonthlyCents}} +
+

Reserved for goals

+
€0
+

per month

+
+
+

Free to spend

+
€0
+

after goals

+
+ {{end}}
{{end}} @@ -101,13 +121,28 @@ {{end}}
- -
- - - -
+ +
+
+ + + {{if .Committed}} + + {{else}} + + {{end}} +
+
+ + + +
+
{{end}} From 2324f62721c1103695c67cde37c4a69270fc7612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 16:41:48 +0100 Subject: [PATCH 2/2] feat: add fixed costs panel to dashboard Co-Authored-By: Claude Sonnet 4.6 --- .../api/main/templates/dashboard.html | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/finance/services/api/main/templates/dashboard.html b/apps/finance/services/api/main/templates/dashboard.html index 02b62a7..48fc4de 100644 --- a/apps/finance/services/api/main/templates/dashboard.html +++ b/apps/finance/services/api/main/templates/dashboard.html @@ -224,4 +224,35 @@ + +{{if $d.RecurringExpenses}} +
+
+

Fixed costs

+ auto-detected · 3-month average +
+
+ {{range $d.RecurringExpenses}} + {{$color := index $d.CategoryColors .Category}} +
+
+ {{if $color}}{{end}} +
+
{{.Category}}
+
committed monthly cost
+
+
+
+
− €{{cents .MonthlyCents}}
+
/ month
+
+
+ {{end}} +
+ Total fixed + − €{{cents $d.BankShouldBe}} +
+
+
+{{end}} {{end}}