Merge pull request #9 from GoncaloRodri/feature/phase3-goals-commit

feat: phase 3 — goals commit + conflict detection
This commit is contained in:
Gonçalo Rodrigues 2026-06-13 16:49:39 +01:00 committed by GitHub
commit 2e3fcb0e69
5 changed files with 154 additions and 22 deletions

View File

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

View File

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

View File

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

View File

@ -224,4 +224,35 @@
</div>
</div>
{{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;">
<h2>Fixed costs</h2>
<span style="font-size:11px; color:var(--text3);">auto-detected · 3-month average</span>
</div>
<div style="display:flex; flex-direction:column;">
{{range $d.RecurringExpenses}}
{{$color := index $d.CategoryColors .Category}}
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--border);">
<div style="display:flex; align-items:center; gap:10px;">
{{if $color}}<span style="width:9px; height:9px; border-radius:50%; background:{{$color}}; flex-shrink:0; display:inline-block;"></span>{{end}}
<div>
<div style="font-size:13px; font-weight:500; color:var(--text);">{{.Category}}</div>
<div style="font-size:11px; color:var(--text3);">committed monthly cost</div>
</div>
</div>
<div style="text-align:right;">
<div style="font-size:14px; font-weight:600; color:var(--red);"> €{{cents .MonthlyCents}}</div>
<div style="font-size:11px; color:var(--text3);">/ month</div>
</div>
</div>
{{end}}
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
<span style="font-size:13px; font-weight:500; color:var(--text);">Total fixed</span>
<span style="font-size:15px; font-weight:600; color:var(--red);"> €{{cents $d.BankShouldBe}}</span>
</div>
</div>
</div>
{{end}}
{{end}}

View File

@ -7,6 +7,13 @@
class="btn btn-primary btn-sm">+ New goal</button>
</div>
{{if $d.ConflictWarning}}
<div style="padding:14px 18px; border-radius:10px; margin-bottom:16px; font-size:13px;
background:rgba(248,113,113,0.08); border:1px solid rgba(248,113,113,0.25); color:var(--red);">
⚠ {{$d.ConflictWarning}}
</div>
{{end}}
{{if $d.AvgMonthlySavings}}
<div style="display:flex; gap:10px; margin-bottom:20px; flex-wrap:wrap;">
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
@ -17,8 +24,21 @@
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
<h2>Disposable income</h2>
<div class="value animate-counter" data-target="{{$d.DisposableIncome}}" data-prefix="€" style="color:var(--text);">€0</div>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">this month</p>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">before goals</p>
</div>
{{if $d.CommittedMonthlyCents}}
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
<h2>Reserved for goals</h2>
<div class="value negative animate-counter" data-target="{{$d.CommittedMonthlyCents}}" data-prefix="€">€0</div>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">per month</p>
</div>
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
<h2>Free to spend</h2>
<div class="value animate-counter {{if lt $d.RemainingDisposable 0}}negative{{else}}positive{{end}}"
data-target="{{$d.RemainingDisposable}}" data-prefix="€">€0</div>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">after goals</p>
</div>
{{end}}
</div>
{{end}}
@ -101,13 +121,28 @@
{{end}}
</div>
<!-- delete -->
<form method="POST" action="/goals" style="margin-top:10px; text-align:right;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red)33;"
onclick="return confirm('Remove this goal?')">Remove</button>
</form>
<!-- commit / uncommit / delete -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:14px; flex-wrap:wrap; gap:8px;">
<form method="POST" action="/goals">
<input type="hidden" name="action" value="{{if .Committed}}uncommit{{else}}commit{{end}}">
<input type="hidden" name="id" value="{{.ID}}">
{{if .Committed}}
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--green); border-color:var(--green)55;">
✓ Committed — click to uncommit
</button>
{{else}}
<button type="submit" class="btn btn-primary btn-sm">
Commit to this goal
</button>
{{end}}
</form>
<form method="POST" action="/goals">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red)33;"
onclick="return confirm('Remove this goal?')">Remove</button>
</form>
</div>
</div>
{{end}}
</div>