feat: phase 3 — goals commit + plan
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
1712ac3851
commit
3b041267ad
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user