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
|
// disposable income = income - fixed recurring
|
||||||
disposableIncome := thisMonthIncome - totalFixedCents
|
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)
|
// variable spend so far this month (non-fixed categories, expenses only)
|
||||||
variableSpent := int64(0)
|
variableSpent := int64(0)
|
||||||
for cat, amt := range thisMonth.ByCategory {
|
for cat, amt := range thisMonth.ByCategory {
|
||||||
@ -1347,6 +1368,13 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// create goal
|
||||||
name := r.FormValue("name")
|
name := r.FormValue("name")
|
||||||
goalType := GoalType(r.FormValue("type"))
|
goalType := GoalType(r.FormValue("type"))
|
||||||
@ -1454,6 +1482,31 @@ 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{
|
render(w, goalsTmpl, &GoalsData{
|
||||||
UserID: a.UserID,
|
UserID: a.UserID,
|
||||||
Email: a.Email,
|
Email: a.Email,
|
||||||
@ -1462,6 +1515,9 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
|
|||||||
Goals: plans,
|
Goals: plans,
|
||||||
AvgMonthlySavings: avgMonthlySavings,
|
AvgMonthlySavings: avgMonthlySavings,
|
||||||
DisposableIncome: disposable,
|
DisposableIncome: disposable,
|
||||||
|
CommittedMonthlyCents: committedTotal,
|
||||||
|
RemainingDisposable: remainingDisposable,
|
||||||
|
ConflictWarning: conflictWarning,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -254,6 +254,9 @@ type GoalsData struct {
|
|||||||
Title string
|
Title string
|
||||||
Route string
|
Route string
|
||||||
Goals []GoalPlan
|
Goals []GoalPlan
|
||||||
AvgMonthlySavings int64 // 3-month average savings for projection
|
AvgMonthlySavings int64
|
||||||
DisposableIncome int64 // from current month dashboard calc
|
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
|
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 {
|
func (s *Store) deleteGoal(ctx context.Context, id, userID string) error {
|
||||||
ctx, span := mongo.StartSpan(ctx, "Store.deleteGoal")
|
ctx, span := mongo.StartSpan(ctx, "Store.deleteGoal")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|||||||
@ -7,6 +7,13 @@
|
|||||||
class="btn btn-primary btn-sm">+ New goal</button>
|
class="btn btn-primary btn-sm">+ New goal</button>
|
||||||
</div>
|
</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}}
|
{{if $d.AvgMonthlySavings}}
|
||||||
<div style="display:flex; gap:10px; margin-bottom:20px; flex-wrap:wrap;">
|
<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;">
|
<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;">
|
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
||||||
<h2>Disposable income</h2>
|
<h2>Disposable income</h2>
|
||||||
<div class="value animate-counter" data-target="{{$d.DisposableIncome}}" data-prefix="€" style="color:var(--text);">€0</div>
|
<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>
|
</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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
@ -101,14 +121,29 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- delete -->
|
<!-- commit / uncommit / delete -->
|
||||||
<form method="POST" action="/goals" style="margin-top:10px; text-align:right;">
|
<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="action" value="delete">
|
||||||
<input type="hidden" name="id" value="{{.ID}}">
|
<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;"
|
<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>
|
onclick="return confirm('Remove this goal?')">Remove</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user