Merge pull request #8 from GoncaloRodri/feature/phase2-goals

feat: phase 2 — goals explore mode
This commit is contained in:
Gonçalo Rodrigues 2026-06-13 16:35:36 +01:00 committed by GitHub
commit 1712ac3851
5 changed files with 473 additions and 0 deletions

View File

@ -115,6 +115,7 @@ var (
projectionsTmpl = parseTmpl("templates/base.html", "templates/projections.html") projectionsTmpl = parseTmpl("templates/base.html", "templates/projections.html")
portfolioTmpl = parseTmpl("templates/base.html", "templates/portfolio.html") portfolioTmpl = parseTmpl("templates/base.html", "templates/portfolio.html")
sharingTmpl = parseTmpl("templates/base.html", "templates/sharing.html") sharingTmpl = parseTmpl("templates/base.html", "templates/sharing.html")
goalsTmpl = parseTmpl("templates/base.html", "templates/goals.html")
) )
type authInfo struct { type authInfo struct {
@ -1328,6 +1329,156 @@ func (h *Handler) healthz(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok")) w.Write([]byte("ok"))
} }
func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
a := getAuth(r)
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
action := r.FormValue("action")
if action == "delete" {
id := r.FormValue("id")
h.store.deleteGoal(ctx, id, a.UserID)
http.Redirect(w, r, "/goals", http.StatusSeeOther)
return
}
// create goal
name := r.FormValue("name")
goalType := GoalType(r.FormValue("type"))
targetStr := r.FormValue("target_euros")
deadlineStr := r.FormValue("deadline")
targetEuros := parseFloat(targetStr)
deadline, _ := time.Parse("2006-01", deadlineStr)
g := &Goal{
ID: bson.NewObjectID().Hex(),
UserID: a.UserID,
Name: name,
Type: goalType,
TargetCents: int64(targetEuros * 100),
Deadline: deadline,
CreatedAt: time.Now(),
}
if err := h.store.createGoal(ctx, g); err != nil {
slog.Error("create goal", "err", err)
}
http.Redirect(w, r, "/goals", http.StatusSeeOther)
return
}
goals, err := h.store.getGoals(ctx, a.UserID)
if err != nil {
slog.Error("get goals", "err", err)
}
// compute average monthly savings over last 3 months
txns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{})
now := time.Now()
threeMonthsAgo := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -3, 0)
monthlySavings := make(map[int]int64)
for _, t := range txns {
if !t.Date.Before(threeMonthsAgo) && t.Date.Before(time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())) {
monthlySavings[int(t.Date.Month())] += t.AmountCents
}
}
var totalSavings int64
for _, s := range monthlySavings {
if s > 0 {
totalSavings += s
}
}
avgMonthlySavings := int64(0)
if len(monthlySavings) > 0 {
avgMonthlySavings = totalSavings / int64(len(monthlySavings))
}
// compute disposable income from this month's transactions
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
thisMonthIncome := int64(0)
fixedThisMonth := int64(0)
for _, t := range txns {
if t.Date.Before(thisStart) {
continue
}
if t.AmountCents > 0 {
thisMonthIncome += t.AmountCents
}
if FixedCategories[t.Category] && t.AmountCents < 0 {
fixedThisMonth += -t.AmountCents
}
}
disposable := thisMonthIncome - fixedThisMonth
// build goal plans
var plans []GoalPlan
for _, g := range goals {
remaining := g.TargetCents - g.SavedCents
if remaining < 0 {
remaining = 0
}
monthsLeft := int64(monthsBetween(now, g.Deadline))
if monthsLeft < 1 {
monthsLeft = 1
}
monthlyCents := remaining / monthsLeft
monthsAtRate := int64(0)
if avgMonthlySavings > 0 {
monthsAtRate = remaining / avgMonthlySavings
}
progressPct := int64(0)
if g.TargetCents > 0 {
progressPct = int64(float64(g.SavedCents) / float64(g.TargetCents) * 100)
if progressPct > 100 {
progressPct = 100
}
}
plans = append(plans, GoalPlan{
Goal: g,
MonthsLeft: monthsLeft,
MonthlyCents: monthlyCents,
ImpactOnDisposable: disposable - monthlyCents,
MonthsAtCurrentRate: monthsAtRate,
Feasible: avgMonthlySavings >= monthlyCents,
ProgressPct: progressPct,
})
}
render(w, goalsTmpl, &GoalsData{
UserID: a.UserID,
Email: a.Email,
Title: "Goals",
Route: "goals",
Goals: plans,
AvgMonthlySavings: avgMonthlySavings,
DisposableIncome: disposable,
})
}
func monthsBetween(from, to time.Time) int {
months := (to.Year()-from.Year())*12 + int(to.Month()) - int(from.Month())
if months < 0 {
return 0
}
return months
}
func parseFloat(s string) float64 {
var f float64
fmt.Sscanf(s, "%f", &f)
return f
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) { func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", h.Dashboard) mux.HandleFunc("GET /{$}", h.Dashboard)
mux.HandleFunc("GET /transactions", h.Transactions) mux.HandleFunc("GET /transactions", h.Transactions)
@ -1345,6 +1496,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /reports", h.Reports) mux.HandleFunc("GET /reports", h.Reports)
mux.HandleFunc("GET /projections", h.Projections) mux.HandleFunc("GET /projections", h.Projections)
mux.HandleFunc("GET /portfolio", h.Portfolio) mux.HandleFunc("GET /portfolio", h.Portfolio)
mux.HandleFunc("GET /goals", h.Goals)
mux.HandleFunc("POST /goals", h.Goals)
mux.HandleFunc("GET /sharing", h.Sharing) mux.HandleFunc("GET /sharing", h.Sharing)
mux.HandleFunc("POST /sharing", h.Sharing) mux.HandleFunc("POST /sharing", h.Sharing)
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing) mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)

View File

@ -214,3 +214,46 @@ type SharingUser struct {
ID string ID string
Email string Email string
} }
// GoalType classifies a financial goal for display and calculation purposes.
type GoalType string
const (
GoalTypeOnce GoalType = "once" // one-off purchase (Switch, holiday)
GoalTypeDeposit GoalType = "deposit" // house deposit / down-payment
GoalTypeEmergency GoalType = "emergency" // emergency fund (N months of expenses)
GoalTypeInvestment GoalType = "investment" // recurring investment target
)
type Goal struct {
ID string `bson:"_id" json:"id"`
UserID string `bson:"user_id" json:"user_id"`
Name string `bson:"name" json:"name"`
Type GoalType `bson:"type" json:"type"`
TargetCents int64 `bson:"target_cents" json:"target_cents"`
SavedCents int64 `bson:"saved_cents" json:"saved_cents"`
Deadline time.Time `bson:"deadline" json:"deadline"`
Committed bool `bson:"committed" json:"committed"` // Phase 3: false until user commits
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// GoalPlan is computed at request time — never stored.
type GoalPlan struct {
Goal
MonthsLeft int64
MonthlyCents int64
ImpactOnDisposable int64
MonthsAtCurrentRate int64
Feasible bool
ProgressPct int64
}
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
}

View File

@ -260,6 +260,40 @@ func (s *Store) createPermission(ctx context.Context, p *Permission) error {
return err return err
} }
func (s *Store) goals() *mgmongo.Collection {
return s.db.Collection("finance_goals")
}
func (s *Store) getGoals(ctx context.Context, userID string) ([]Goal, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getGoals")
defer span.End()
opts := options.Find().SetSort(bson.M{"created_at": 1})
cur, err := s.goals().Find(ctx, bson.M{"user_id": userID}, opts)
if err != nil {
return nil, fmt.Errorf("find goals: %w", err)
}
defer cur.Close(ctx)
var goals []Goal
if err := cur.All(ctx, &goals); err != nil {
return nil, fmt.Errorf("decode goals: %w", err)
}
return goals, nil
}
func (s *Store) createGoal(ctx context.Context, g *Goal) error {
ctx, span := mongo.StartSpan(ctx, "Store.createGoal")
defer span.End()
_, err := s.goals().InsertOne(ctx, g)
return err
}
func (s *Store) deleteGoal(ctx context.Context, id, userID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteGoal")
defer span.End()
_, err := s.goals().DeleteOne(ctx, bson.M{"_id": id, "user_id": userID})
return err
}
func (s *Store) deletePermission(ctx context.Context, ownerID, viewerID string) error { func (s *Store) deletePermission(ctx context.Context, ownerID, viewerID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deletePermission") ctx, span := mongo.StartSpan(ctx, "Store.deletePermission")
defer span.End() defer span.End()

View File

@ -399,6 +399,7 @@
<a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</a> <a href="/reports" class="{{if eq .Route "reports"}}active{{end}}">Reports</a>
<a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">Projections</a> <a href="/projections" class="{{if eq .Route "projections"}}active{{end}}">Projections</a>
<a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a> <a href="/portfolio" class="{{if eq .Route "portfolio"}}active{{end}}">Portfolio</a>
<a href="/goals" class="{{if eq .Route "goals"}}active{{end}}">Goals</a>
<a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a> <a href="/sharing" class="{{if eq .Route "sharing"}}active{{end}}">Sharing</a>
<div class="nav-spacer"></div> <div class="nav-spacer"></div>
<span class="nav-email">{{.Email}}</span> <span class="nav-email">{{.Email}}</span>

View File

@ -0,0 +1,242 @@
{{define "content"}}
{{$d := .}}
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
<h1>Goals</h1>
<button onclick="document.getElementById('new-goal-modal').style.display='flex'"
class="btn btn-primary btn-sm">+ New goal</button>
</div>
{{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;">
<h2>Avg monthly savings</h2>
<div class="value positive animate-counter" data-target="{{$d.AvgMonthlySavings}}" data-prefix="€">€0</div>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">last 3 months</p>
</div>
<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>
</div>
</div>
{{end}}
{{if $d.Goals}}
<div style="display:flex; flex-direction:column; gap:14px;">
{{range $d.Goals}}
<div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<!-- left: name + type + progress -->
<div style="flex:1; min-width:200px;">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:4px;">
<span style="font-size:18px;">{{if eq .Type "once"}}🎯{{else if eq .Type "deposit"}}🏠{{else if eq .Type "emergency"}}🛡️{{else}}📈{{end}}</span>
<div>
<div style="font-size:15px; font-weight:600; color:var(--text);">{{.Name}}</div>
<div style="font-size:11px; color:var(--text3); text-transform:uppercase; letter-spacing:.4px;">
{{if eq .Type "once"}}One-off purchase{{else if eq .Type "deposit"}}Deposit / down-payment{{else if eq .Type "emergency"}}Emergency fund{{else}}Recurring investment{{end}}
</div>
</div>
</div>
<div style="margin-top:12px;">
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
<span style="font-size:12px; color:var(--text2);">€{{cents .SavedCents}} saved of €{{cents .TargetCents}}</span>
<span style="font-size:12px; color:var(--text2);">{{.ProgressPct}}%</span>
</div>
<div style="background:var(--bg3); border-radius:99px; height:6px; 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>
</div>
<!-- right: projection numbers -->
<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;">
<div style="text-align:center;">
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">Need per month</div>
<div class="animate-counter" style="font-size:18px; font-weight:600; color:var(--text);"
data-target="{{.MonthlyCents}}" data-prefix="€">€0</div>
</div>
<div style="text-align:center;">
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">Months left</div>
<div style="font-size:18px; font-weight:600; color:var(--text);">{{.MonthsLeft}}</div>
</div>
<div style="text-align:center;">
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">At current rate</div>
{{if gt .MonthsAtCurrentRate 0}}
<div style="font-size:18px; font-weight:600;
{{if .Feasible}}color:var(--green){{else}}color:var(--red){{end}};">
{{.MonthsAtCurrentRate}}mo
</div>
{{else}}
<div style="font-size:14px; color:var(--text3);"></div>
{{end}}
</div>
<div style="text-align:center;">
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">Disposable after</div>
<div class="animate-counter" style="font-size:18px; font-weight:600;
{{if ge .ImpactOnDisposable 0}}color:var(--green){{else}}color:var(--red){{end}};"
data-target="{{.ImpactOnDisposable}}" data-prefix="€">€0</div>
</div>
</div>
</div>
<!-- feasibility banner -->
<div style="margin-top:14px; padding:10px 14px; border-radius:8px; font-size:13px;
background:{{if .Feasible}}rgba(74,222,128,0.08){{else}}rgba(248,113,113,0.08){{end}};
border:1px solid {{if .Feasible}}rgba(74,222,128,0.2){{else}}rgba(248,113,113,0.2){{end}};
color:{{if .Feasible}}var(--green){{else}}var(--red){{end}};">
{{if .Feasible}}
✓ On track — your current savings rate covers the required €{{cents .MonthlyCents}}/month with {{.MonthsLeft}} months to go.
{{else}}
⚠ At your current savings rate (€{{cents $d.AvgMonthlySavings}}/mo) you'd reach this in {{.MonthsAtCurrentRate}} months, missing the deadline by {{sub .MonthsAtCurrentRate .MonthsLeft}} months.
{{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>
</div>
{{end}}
</div>
{{else}}
<div class="card empty-state animate-on-scroll">
<div style="font-size:48px; margin-bottom:16px;">🎯</div>
<h3>No goals yet</h3>
<p style="margin-bottom:20px;">Plan a purchase, a house deposit, or an emergency fund.<br>See exactly how much you need to save each month to get there.</p>
<button onclick="document.getElementById('new-goal-modal').style.display='flex'" class="btn btn-primary">
Add your first goal
</button>
</div>
{{end}}
<!-- New goal modal -->
<div id="new-goal-modal" style="display:none; position:fixed; inset:0; z-index:1000;
background:rgba(0,0,0,0.6); backdrop-filter:blur(4px);
align-items:center; justify-content:center; padding:20px;">
<div style="background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius);
padding:28px; width:100%; max-width:460px; position:relative;">
<h2 style="margin-bottom:20px;">New goal</h2>
<form method="POST" action="/goals" style="display:flex; flex-direction:column; gap:16px;">
<div>
<label style="font-size:12px; color:var(--text2); display:block; margin-bottom:6px;">Goal name</label>
<input name="name" required placeholder="e.g. Nintendo Switch, House deposit…"
style="width:100%; padding:9px 12px; background:var(--bg3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:14px;">
</div>
<div>
<label style="font-size:12px; color:var(--text2); display:block; margin-bottom:6px;">Type</label>
<select name="type" id="goal-type" onchange="updateTypeHint(this.value)"
style="width:100%; padding:9px 12px; background:var(--bg3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:14px;">
<option value="once">🎯 One-off purchase</option>
<option value="deposit">🏠 Deposit / down-payment</option>
<option value="emergency">🛡️ Emergency fund</option>
<option value="investment">📈 Recurring investment</option>
</select>
<p id="type-hint" style="font-size:12px; color:var(--text3); margin-top:5px;">
A specific purchase you want to save up for.
</p>
</div>
<div>
<label style="font-size:12px; color:var(--text2); display:block; margin-bottom:6px;">Target amount (€)</label>
<input name="target_euros" type="number" min="1" step="0.01" required placeholder="500"
style="width:100%; padding:9px 12px; background:var(--bg3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:14px;">
</div>
<div>
<label style="font-size:12px; color:var(--text2); display:block; margin-bottom:6px;">Target date</label>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px;">
<select id="deadline-month"
style="padding:9px 12px; background:var(--bg3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:14px;">
<option value="01">January</option>
<option value="02">February</option>
<option value="03">March</option>
<option value="04">April</option>
<option value="05">May</option>
<option value="06">June</option>
<option value="07">July</option>
<option value="08">August</option>
<option value="09">September</option>
<option value="10">October</option>
<option value="11">November</option>
<option value="12">December</option>
</select>
<select id="deadline-year"
style="padding:9px 12px; background:var(--bg3); border:1px solid var(--border);
border-radius:8px; color:var(--text); font-size:14px;">
</select>
</div>
<!-- hidden field submitted to server -->
<input type="hidden" name="deadline" id="deadline-value">
</div>
<div style="display:flex; gap:10px; justify-content:flex-end; margin-top:4px;">
<button type="button" onclick="document.getElementById('new-goal-modal').style.display='none'"
class="btn btn-outline">Cancel</button>
<button type="submit" class="btn btn-primary">Save goal</button>
</div>
</form>
</div>
</div>
<script>
const typeHints = {
once: 'A specific purchase you want to save up for.',
deposit: 'A lump sum needed for a house deposit or down-payment.',
emergency: 'A safety net covering several months of expenses.',
investment: 'A recurring investment target (e.g. ETF monthly contribution).',
};
function updateTypeHint(v) {
document.getElementById('type-hint').textContent = typeHints[v] || '';
}
// populate year dropdown: current year + 10 years ahead
(function() {
const now = new Date();
const yearSel = document.getElementById('deadline-year');
const monthSel = document.getElementById('deadline-month');
for (let y = now.getFullYear(); y <= now.getFullYear() + 10; y++) {
const opt = document.createElement('option');
opt.value = String(y);
opt.textContent = String(y);
yearSel.appendChild(opt);
}
// default month to next month
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
monthSel.value = String(nextMonth.getMonth() + 1).padStart(2, '0');
yearSel.value = String(nextMonth.getFullYear());
})();
// wire hidden field before submit
document.querySelector('#new-goal-modal form').addEventListener('submit', function() {
const m = document.getElementById('deadline-month').value;
const y = document.getElementById('deadline-year').value;
document.getElementById('deadline-value').value = y + '-' + m;
});
// close modal on backdrop click
document.getElementById('new-goal-modal').addEventListener('click', function(e) {
if (e.target === this) this.style.display = 'none';
});
</script>
{{end}}