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")
|
||||
portfolioTmpl = parseTmpl("templates/base.html", "templates/portfolio.html")
|
||||
sharingTmpl = parseTmpl("templates/base.html", "templates/sharing.html")
|
||||
goalsTmpl = parseTmpl("templates/base.html", "templates/goals.html")
|
||||
)
|
||||
|
||||
type authInfo struct {
|
||||
@ -1328,6 +1329,156 @@ func (h *Handler) healthz(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
mux.HandleFunc("GET /{$}", h.Dashboard)
|
||||
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 /projections", h.Projections)
|
||||
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("POST /sharing", h.Sharing)
|
||||
mux.HandleFunc("DELETE /sharing/{viewer_id}", h.Sharing)
|
||||
|
||||
@ -214,3 +214,46 @@ type SharingUser struct {
|
||||
ID 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
|
||||
}
|
||||
|
||||
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 {
|
||||
ctx, span := mongo.StartSpan(ctx, "Store.deletePermission")
|
||||
defer span.End()
|
||||
|
||||
@ -399,6 +399,7 @@
|
||||
<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="/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>
|
||||
<div class="nav-spacer"></div>
|
||||
<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