feat: phase 2 — goals explore mode
Adds a Goals page where users can plan financial goals before committing to them. Each goal shows: - Required monthly contribution to hit the deadline - Months remaining vs months at current savings rate - Disposable income impact (what's left after the contribution) - Feasibility banner (green if on track, red with month delta if not) - Progress bar once savings are tracked Goal types: one-off purchase, deposit/down-payment, emergency fund, recurring investment — each with a description hint in the creation modal. Data: Goal model + store CRUD in finance_goals collection. Nav: Goals tab added between Portfolio and Sharing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
809fc01d48
commit
be0c2bd89e
@ -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 := monthsBetween(now, g.Deadline)
|
||||||
|
if monthsLeft < 1 {
|
||||||
|
monthsLeft = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
monthlyCents := remaining / int64(monthsLeft)
|
||||||
|
|
||||||
|
monthsAtRate := 0
|
||||||
|
if avgMonthlySavings > 0 {
|
||||||
|
monthsAtRate = int(remaining / avgMonthlySavings)
|
||||||
|
}
|
||||||
|
|
||||||
|
progressPct := 0
|
||||||
|
if g.TargetCents > 0 {
|
||||||
|
progressPct = int(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)
|
||||||
|
|||||||
@ -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 int
|
||||||
|
MonthlyCents int64 // required monthly contribution
|
||||||
|
ImpactOnDisposable int64 // how much disposable income this eats
|
||||||
|
MonthsAtCurrentRate int // months to reach goal at current savings rate
|
||||||
|
Feasible bool // can reach it by deadline at required monthly
|
||||||
|
ProgressPct int
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
198
apps/finance/services/api/main/templates/goals.html
Normal file
198
apps/finance/services/api/main/templates/goals.html
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
{{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 style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
||||||
|
<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>
|
||||||
|
<input name="deadline" type="month" required
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// close modal on backdrop click
|
||||||
|
document.getElementById('new-goal-modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) this.style.display = 'none';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
Loading…
x
Reference in New Issue
Block a user