feat(finance): transaction-backed goals + interactive waterfall

Goals are now funded entirely through tagged transactions — no more
manually-maintained saved_cents. Free cash waterfall (income → living →
goals → free cash) is the single source of truth for where money goes.

Core changes:
- Transaction.GoalID field links outflows to goals; SavedCents is derived
  via MongoDB aggregation (getGoalFundedCentsAll) instead of stored
- Waterfall on dashboard and goals page splits outflows into living vs
  goal-funded using GoalID presence
- ImpactOnDisposable fixed: uses income−living−monthlyCents instead of
  waterfallFreeCash−monthlyCents (was double-subtracting goal spend)
- avgMonthlySavings fixed: divides by positive-saving months only, and
  uses year+month key to avoid Dec cross-year collision

Interactive waterfall drill-down:
- Click Income / Living / Goals rows to expand category breakdown
- Click a category to reveal individual transactions inline
- All rendered server-side (instant, no extra API call)
- New WaterfallRow type + IncomeCats/LivingCats/IncomeCatTxns/LivingCatTxns
  on DashboardData

Goals page:
- Summary cards switched from heuristic disposable/committed to waterfall
- Each goal card shows funding history (last 5 tagged transactions)
- "Fund this goal" button links to /transactions?fund_goal=<id>

Transactions page:
- Add Transaction modal has goal picker dropdown
- submitAdd() includes goal_id in POST body
- Auto-opens modal pre-selected when arriving from goals page

Seed:
- seedGoalTransactions() back-fills tagged contributions for all 4 demo
  goals (Emergency fund, House down payment, Japan trip, MacBook Pro)
- Idempotent — skips if goal-tagged transactions already exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-19 22:18:47 +01:00
parent ccbb60ace9
commit 5f60d963a0
9 changed files with 616 additions and 383 deletions

View File

@ -277,6 +277,8 @@ type storeIface interface {
createGoal(ctx context.Context, g *Goal) error createGoal(ctx context.Context, g *Goal) error
updateGoal(ctx context.Context, id, userID string, update bson.M) error updateGoal(ctx context.Context, id, userID string, update bson.M) error
deleteGoal(ctx context.Context, id, userID string) error deleteGoal(ctx context.Context, id, userID string) error
getGoalFundedCentsAll(ctx context.Context, userID string) (map[string]int64, error)
getGoalTransactions(ctx context.Context, userID, goalID string) ([]Transaction, error)
seedCategories(ctx context.Context, userID string) error seedCategories(ctx context.Context, userID string) error
getTickerMappings(ctx context.Context, userID string) ([]TickerMapping, error) getTickerMappings(ctx context.Context, userID string) ([]TickerMapping, error)
saveTickerMapping(ctx context.Context, userID, isin, ticker string) error saveTickerMapping(ctx context.Context, userID, isin, ticker string) error
@ -465,7 +467,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
now := time.Now() now := time.Now()
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
lastStart := thisStart.AddDate(0, -1, 0) lastStart := thisStart.AddDate(0, -1, 0)
threeMonthsAgo := thisStart.AddDate(0, -3, 0)
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{}) txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
if err != nil { if err != nil {
@ -493,9 +494,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
thisMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames} thisMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames}
lastMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames} lastMonth := &PeriodSummary{ByCategory: make(map[string]int64), CategoryNames: catNames}
// fixed spending by category over the last 3 months (for recurring detection)
fixedByMonth := make(map[string]map[int]int64) // category -> month-offset -> total
var recent []Transaction var recent []Transaction
var balPoints []BalancePoint var balPoints []BalancePoint
balByDate := make(map[string]int64) balByDate := make(map[string]int64)
@ -504,7 +502,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
for _, t := range txns { for _, t := range txns {
isThisMonth := !t.Date.Before(thisStart) isThisMonth := !t.Date.Before(thisStart)
isLastMonth := !t.Date.Before(lastStart) && t.Date.Before(thisStart) isLastMonth := !t.Date.Before(lastStart) && t.Date.Before(thisStart)
isRecent3 := !t.Date.Before(threeMonthsAgo) && t.Date.Before(thisStart)
if isThisMonth { if isThisMonth {
thisMonth.TotalCents += t.AmountCents thisMonth.TotalCents += t.AmountCents
@ -514,15 +511,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
lastMonth.ByCategory[t.Category] += t.AmountCents lastMonth.ByCategory[t.Category] += t.AmountCents
} }
// accumulate fixed category spending over last 3 months
if isRecent3 && FixedCategories[t.Category] && t.AmountCents < 0 {
mo := int(t.Date.Month())
if fixedByMonth[t.Category] == nil {
fixedByMonth[t.Category] = make(map[int]int64)
}
fixedByMonth[t.Category][mo] += -t.AmountCents
}
if len(recent) < 5 { if len(recent) < 5 {
recent = append(recent, t) recent = append(recent, t)
} }
@ -565,97 +553,42 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
lastMonthSavings = 0 lastMonthSavings = 0
} }
// detect recurring fixed expenses (average over last 3 months) // transaction-backed waterfall: split this month's transactions into buckets
var recurringExpenses []RecurringExpense waterfallLiving := int64(0)
totalFixedCents := int64(0) waterfallGoals := int64(0)
for cat, byMonth := range fixedByMonth { incomeByCat := make(map[string]int64)
total := int64(0) livingByCat := make(map[string]int64)
for _, v := range byMonth { incomeCatTxns := make(map[string][]Transaction)
total += v livingCatTxns := make(map[string][]Transaction)
} goalFundedThisMonth := make(map[string]int64)
avg := total / int64(len(byMonth)) for _, t := range txns {
recurringExpenses = append(recurringExpenses, RecurringExpense{Category: cat, MonthlyCents: avg}) if t.Date.Before(thisStart) {
totalFixedCents += avg
}
sort.Slice(recurringExpenses, func(i, j int) bool {
return recurringExpenses[i].MonthlyCents > recurringExpenses[j].MonthlyCents
})
// disposable income = income - fixed recurring
disposableIncome := thisMonthIncome - totalFixedCents
// deduct committed goal contributions from disposable and add to fixed costs list
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 continue
} }
remaining := g.TargetCents - g.SavedCents if t.AmountCents > 0 {
if remaining <= 0 { incomeByCat[t.Category] += t.AmountCents
continue incomeCatTxns[t.Category] = append(incomeCatTxns[t.Category], t)
} } else {
ml := int64(monthsBetween(now2, g.Deadline)) if t.GoalID != "" {
if ml < 1 { waterfallGoals += -t.AmountCents
ml = 1 goalFundedThisMonth[t.GoalID] += -t.AmountCents
} } else {
monthly := remaining / ml waterfallLiving += -t.AmountCents
committedGoalsCents += monthly livingByCat[t.Category] += -t.AmountCents
recurringExpenses = append(recurringExpenses, RecurringExpense{ livingCatTxns[t.Category] = append(livingCatTxns[t.Category], t)
Category: g.Name,
MonthlyCents: monthly,
IsGoal: true,
})
} }
} }
disposableIncome -= committedGoalsCents }
totalCommittedCents := totalFixedCents + committedGoalsCents waterfallFreeCash := thisMonthIncome - waterfallLiving - waterfallGoals
// variable spend so far this month (non-fixed categories, expenses only) // sort category rows by amount descending for the drill-down display
variableSpent := int64(0) incomeCats := sortWaterfallRows(incomeByCat, catColors)
for cat, amt := range thisMonth.ByCategory { livingCats := sortWaterfallRows(livingByCat, catColors)
if !FixedCategories[cat] && amt < 0 {
variableSpent += -amt
}
}
availableToSpend := disposableIncome - variableSpent
if availableToSpend < 0 {
availableToSpend = 0
}
// month progress // month progress
daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day() daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
monthProgressPct := int(float64(now.Day()) / float64(daysInMonth) * 100) monthProgressPct := int(float64(now.Day()) / float64(daysInMonth) * 100)
// % of disposable already spent
monthSpentPct := 0
if disposableIncome > 0 {
monthSpentPct = int(float64(variableSpent) / float64(disposableIncome) * 100)
if monthSpentPct > 100 {
monthSpentPct = 100
}
}
// safety buffer = 2 weeks of average daily variable spend over last month
lastMonthVariableSpent := int64(0)
for cat, amt := range lastMonth.ByCategory {
if !FixedCategories[cat] && amt < 0 {
lastMonthVariableSpent += -amt
}
}
safetyBuffer := lastMonthVariableSpent / 2
// bank should be = upcoming fixed costs (not yet paid this month) + safety buffer
fixedPaidThisMonth := int64(0)
for cat, amt := range thisMonth.ByCategory {
if FixedCategories[cat] && amt < 0 {
fixedPaidThisMonth += -amt
}
}
bankShouldBe := (totalFixedCents - fixedPaidThisMonth) + safetyBuffer
// savings rate // savings rate
savingsRatePct := 0 savingsRatePct := 0
if thisMonthIncome > 0 { if thisMonthIncome > 0 {
@ -712,13 +645,15 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
} }
} }
// ── Committed goals for dashboard widget ───────────────────────────── // ── Committed goals for dashboard widget (tx-backed progress) ────────
var dashGoals []GoalPlan var dashGoals []GoalPlan
goalFunds, _ := h.store.getGoalFundedCentsAll(ctx, a.UserID)
if allGoals, err2 := h.store.getGoals(ctx, a.UserID); err2 == nil { if allGoals, err2 := h.store.getGoals(ctx, a.UserID); err2 == nil {
for _, g := range allGoals { for _, g := range allGoals {
if !g.Committed { if !g.Committed {
continue continue
} }
g.SavedCents = goalFunds[g.ID]
remaining := g.TargetCents - g.SavedCents remaining := g.TargetCents - g.SavedCents
if remaining < 0 { if remaining < 0 {
remaining = 0 remaining = 0
@ -728,10 +663,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
ml = 1 ml = 1
} }
monthly := remaining / ml monthly := remaining / ml
var atRate int64
if avgSavingsForGoals := disposableIncome; avgSavingsForGoals > 0 {
atRate = remaining / avgSavingsForGoals
}
pct := int64(0) pct := int64(0)
if g.TargetCents > 0 { if g.TargetCents > 0 {
pct = g.SavedCents * 100 / g.TargetCents pct = g.SavedCents * 100 / g.TargetCents
@ -743,8 +674,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
Goal: g, Goal: g,
MonthsLeft: ml, MonthsLeft: ml,
MonthlyCents: monthly, MonthlyCents: monthly,
MonthsAtCurrentRate: atRate, Feasible: waterfallFreeCash >= monthly,
Feasible: disposableIncome >= monthly,
ProgressPct: pct, ProgressPct: pct,
}) })
} }
@ -773,57 +703,18 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
} }
} }
// goal deadline risk alerts // goal deadline risk alerts (tx-backed remaining)
if goalList, err := h.store.getGoals(ctx, a.UserID); err == nil { for _, g := range dashGoals {
threeAgo := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -3, 0) if !g.Committed || g.SavedCents >= g.TargetCents {
moSavings := make(map[int]int64)
for _, t := range txns {
if !t.Date.Before(threeAgo) && t.Date.Before(thisStart) {
moSavings[int(t.Date.Month())] += t.AmountCents
}
}
var totalS int64
for _, s := range moSavings {
if s > 0 {
totalS += s
}
}
avgS := int64(0)
if len(moSavings) > 0 {
avgS = totalS / int64(len(moSavings))
}
for _, g := range goalList {
remaining := g.TargetCents - g.SavedCents
if remaining <= 0 {
continue continue
} }
ml := int64(monthsBetween(now, g.Deadline)) if waterfallFreeCash < g.MonthlyCents {
if ml < 1 {
ml = 1
}
needed := remaining / ml
if avgS < needed {
monthsOff := int64(0)
if avgS > 0 {
monthsOff = remaining/avgS - ml
}
msg := fmt.Sprintf("You're on track to miss your \"%s\" goal", g.Name)
if monthsOff > 0 {
msg += fmt.Sprintf(" by %d month(s)", monthsOff)
}
msg += fmt.Sprintf(" — need €%.0f/mo but saving ~€%.0f/mo.", float64(needed)/100, float64(avgS)/100)
alerts = append(alerts, Alert{Level: AlertWarn, Message: msg})
}
}
}
// overall spend pace alert
if monthProgressPct > 0 && monthSpentPct > monthProgressPct+20 {
alerts = append(alerts, Alert{ alerts = append(alerts, Alert{
Level: AlertWarn, Level: AlertWarn,
Message: fmt.Sprintf("You've spent %d%% of your disposable income but only %d%% of the month has passed — you're ahead of pace.", monthSpentPct, monthProgressPct), Message: fmt.Sprintf("Free cash (€%.0f) is below the monthly need for \"%s\" (€%.0f/mo).", float64(waterfallFreeCash)/100, g.Name, float64(g.MonthlyCents)/100),
}) })
} }
}
render(w, dashboardTmpl, &DashboardData{ render(w, dashboardTmpl, &DashboardData{
T: h.t(r), T: h.t(r),
@ -840,14 +731,16 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
ThisMonthExpense: thisMonthExpense, ThisMonthExpense: thisMonthExpense,
CategoryBudgets: catBudgets, CategoryBudgets: catBudgets,
CategoryColors: catColors, CategoryColors: catColors,
AvailableToSpend: availableToSpend, WaterfallIncome: thisMonthIncome,
DisposableIncome: disposableIncome, WaterfallLiving: waterfallLiving,
WaterfallGoals: waterfallGoals,
WaterfallFreeCash: waterfallFreeCash,
IncomeCats: incomeCats,
LivingCats: livingCats,
IncomeCatTxns: incomeCatTxns,
LivingCatTxns: livingCatTxns,
GoalFundedThisMonth: goalFundedThisMonth,
MonthProgressPct: monthProgressPct, MonthProgressPct: monthProgressPct,
MonthSpentPct: monthSpentPct,
RecurringExpenses: recurringExpenses,
BankShouldBe: bankShouldBe,
SafetyBufferCents: safetyBuffer,
TotalCommittedCents: totalCommittedCents,
SavingsRatePct: savingsRatePct, SavingsRatePct: savingsRatePct,
LastMonthSavingsRatePct: lastMonthSavingsRatePct, LastMonthSavingsRatePct: lastMonthSavingsRatePct,
PortfolioValueCents: portfolioValueCents, PortfolioValueCents: portfolioValueCents,
@ -902,6 +795,7 @@ func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) {
cats, _ := h.store.getCategories(ctx, a.UserID) cats, _ := h.store.getCategories(ctx, a.UserID)
accounts, _ := h.store.getAccounts(ctx, a.UserID) accounts, _ := h.store.getAccounts(ctx, a.UserID)
goals, _ := h.store.getGoals(ctx, a.UserID)
accountNames := make(map[string]string) accountNames := make(map[string]string)
for _, acc := range accounts { for _, acc := range accounts {
@ -923,6 +817,7 @@ func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) {
"Txns": txns, "Txns": txns,
"Categories": cats, "Categories": cats,
"Accounts": accounts, "Accounts": accounts,
"Goals": goals,
"AccountNames": accountNames, "AccountNames": accountNames,
"CategoryColors": catColors, "CategoryColors": catColors,
"Cat": cat, "Cat": cat,
@ -942,6 +837,7 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
Description string `json:"description"` Description string `json:"description"`
AmountCents int64 `json:"amount_cents"` AmountCents int64 `json:"amount_cents"`
Category string `json:"category"` Category string `json:"category"`
GoalID string `json:"goal_id"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
@ -961,6 +857,7 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
Description: body.Description, Description: body.Description,
AmountCents: body.AmountCents, AmountCents: body.AmountCents,
Category: body.Category, Category: body.Category,
GoalID: body.GoalID,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
@ -1270,6 +1167,7 @@ func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) {
var body struct { var body struct {
Category string `json:"category"` Category string `json:"category"`
Description string `json:"description"` Description string `json:"description"`
GoalID string `json:"goal_id"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
@ -1283,6 +1181,9 @@ func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) {
if body.Description != "" { if body.Description != "" {
update["description"] = body.Description update["description"] = body.Description
} }
if body.GoalID != "" {
update["goal_id"] = body.GoalID
}
if err := h.store.updateTransaction(ctx, id, a.UserID, update); err != nil { if err := h.store.updateTransaction(ctx, id, a.UserID, update); err != nil {
slog.Error("update transaction", "err", err) slog.Error("update transaction", "err", err)
@ -1905,44 +1806,57 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
// compute average monthly savings over last 3 months // compute average monthly savings over last 3 months
txns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{}) txns, _ := h.store.getTransactions(ctx, a.UserID, bson.M{})
now := time.Now() now := time.Now()
threeMonthsAgo := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -3, 0) thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
monthlySavings := make(map[int]int64) threeMonthsAgo := thisStart.AddDate(0, -3, 0)
// Use year*12+month as key to avoid cross-year collisions
type monthKey struct{ year, month int }
monthlySavings := make(map[monthKey]int64)
for _, t := range txns { 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())) { if !t.Date.Before(threeMonthsAgo) && t.Date.Before(thisStart) {
monthlySavings[int(t.Date.Month())] += t.AmountCents k := monthKey{t.Date.Year(), int(t.Date.Month())}
monthlySavings[k] += t.AmountCents
} }
} }
var totalSavings int64 var totalSavings int64
var posMonths int64
for _, s := range monthlySavings { for _, s := range monthlySavings {
if s > 0 { if s > 0 {
totalSavings += s totalSavings += s
posMonths++
} }
} }
avgMonthlySavings := int64(0) avgMonthlySavings := int64(0)
if len(monthlySavings) > 0 { if posMonths > 0 {
avgMonthlySavings = totalSavings / int64(len(monthlySavings)) avgMonthlySavings = totalSavings / posMonths
} }
// compute disposable income from this month's transactions // transaction-backed waterfall for this month
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) waterfallIncome := int64(0)
thisMonthIncome := int64(0) waterfallLiving := int64(0)
fixedThisMonth := int64(0) waterfallGoals := int64(0)
for _, t := range txns { for _, t := range txns {
if t.Date.Before(thisStart) { if t.Date.Before(thisStart) {
continue continue
} }
if t.AmountCents > 0 { if t.AmountCents > 0 {
thisMonthIncome += t.AmountCents waterfallIncome += t.AmountCents
} } else {
if FixedCategories[t.Category] && t.AmountCents < 0 { if t.GoalID != "" {
fixedThisMonth += -t.AmountCents waterfallGoals += -t.AmountCents
} else {
waterfallLiving += -t.AmountCents
} }
} }
disposable := thisMonthIncome - fixedThisMonth }
waterfallFreeCash := waterfallIncome - waterfallLiving - waterfallGoals
// build goal plans // tx-backed funded amounts and recent contributions per goal
goalFunds, _ := h.store.getGoalFundedCentsAll(ctx, a.UserID)
// build goal plans with tx-backed SavedCents
var plans []GoalPlan var plans []GoalPlan
for _, g := range goals { for _, g := range goals {
g.SavedCents = goalFunds[g.ID]
remaining := g.TargetCents - g.SavedCents remaining := g.TargetCents - g.SavedCents
if remaining < 0 { if remaining < 0 {
remaining = 0 remaining = 0
@ -1968,42 +1882,19 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
} }
} }
fundingTxns, _ := h.store.getGoalTransactions(ctx, a.UserID, g.ID)
plans = append(plans, GoalPlan{ plans = append(plans, GoalPlan{
Goal: g, Goal: g,
MonthsLeft: monthsLeft, MonthsLeft: monthsLeft,
MonthlyCents: monthlyCents, MonthlyCents: monthlyCents,
ImpactOnDisposable: disposable - monthlyCents, ImpactOnDisposable: waterfallIncome - waterfallLiving - monthlyCents,
MonthsAtCurrentRate: monthsAtRate, MonthsAtCurrentRate: monthsAtRate,
Feasible: avgMonthlySavings >= monthlyCents, Feasible: avgMonthlySavings >= monthlyCents,
ProgressPct: progressPct, ProgressPct: progressPct,
FundingTxns: fundingTxns,
}) })
} }
// 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
}
data := &GoalsData{ data := &GoalsData{
T: h.t(r), T: h.t(r),
UserID: a.UserID, UserID: a.UserID,
@ -2013,10 +1904,10 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
Tab: r.URL.Query().Get("tab"), Tab: r.URL.Query().Get("tab"),
Goals: plans, Goals: plans,
AvgMonthlySavings: avgMonthlySavings, AvgMonthlySavings: avgMonthlySavings,
DisposableIncome: disposable, WaterfallIncome: waterfallIncome,
CommittedMonthlyCents: committedTotal, WaterfallLiving: waterfallLiving,
RemainingDisposable: remainingDisposable, WaterfallGoals: waterfallGoals,
ConflictWarning: conflictWarning, WaterfallFreeCash: waterfallFreeCash,
} }
if data.Tab == "" { if data.Tab == "" {
data.Tab = "goals" data.Tab = "goals"
@ -2912,6 +2803,16 @@ func sortStrings(s []string) {
sort.Strings(s) sort.Strings(s)
} }
// sortWaterfallRows converts a category→cents map into a slice sorted by amount descending.
func sortWaterfallRows(byCat map[string]int64, colors map[string]string) []WaterfallRow {
rows := make([]WaterfallRow, 0, len(byCat))
for name, cents := range byCat {
rows = append(rows, WaterfallRow{Name: name, Color: colors[name], Cents: cents})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Cents > rows[j].Cents })
return rows
}
func appendIfMissing(s []string, v string) []string { func appendIfMissing(s []string, v string) []string {
for _, x := range s { for _, x := range s {
if x == v { if x == v {

View File

@ -243,6 +243,15 @@ total_committed = "Total committed"
vs_last_month_up = "↑ vs last month" vs_last_month_up = "↑ vs last month"
vs_last_month_down = "↓ vs last month" vs_last_month_down = "↓ vs last month"
[dashboard.waterfall]
title = "Cash flow this month"
income = "Income"
living = "Living expenses"
goals = "Goal contributions"
goals_link = "manage →"
free_cash = "Free cash"
month_progress = "Month progress"
# ── Transactions ───────────────────────────────────────────────────────────── # ── Transactions ─────────────────────────────────────────────────────────────
[transactions] [transactions]
@ -287,6 +296,8 @@ placeholder_amount = "0.00"
label_category = "Category" label_category = "Category"
label_account = "Account" label_account = "Account"
option_no_account = "— none —" option_no_account = "— none —"
label_goal = "Fund a goal (optional)"
option_no_goal = "— not a goal contribution —"
btn_cancel = "Cancel" btn_cancel = "Cancel"
btn_save = "Save Transaction" btn_save = "Save Transaction"
error_required = "Please fill in date, description, and a positive amount." error_required = "Please fill in date, description, and a positive amount."
@ -338,11 +349,11 @@ tab_planner = "Goal Planner"
[goals.summary_cards] [goals.summary_cards]
avg_monthly_savings = "Avg monthly savings" avg_monthly_savings = "Avg monthly savings"
last_3_months = "last 3 months" last_3_months = "last 3 months"
disposable_income = "Disposable income" income = "Income"
before_goals = "before goals" this_month = "this month"
reserved_for_goals = "Reserved for goals" goal_funded = "Funded to goals"
per_month = "per month" tagged_transactions = "via tagged transactions"
free_to_spend = "Free to spend" free_cash = "Free cash"
after_goals = "after goals" after_goals = "after goals"
[goals.goal_card] [goals.goal_card]
@ -362,6 +373,9 @@ btn_committed = "✓ Committed — click to uncommit"
btn_commit = "Commit to this goal" btn_commit = "Commit to this goal"
btn_remove = "Remove" btn_remove = "Remove"
confirm_remove = "Remove this goal?" confirm_remove = "Remove this goal?"
btn_fund = "Fund this goal"
funding_history = "Recent contributions"
no_funding_yet = "No transactions tagged yet. Add a transaction and link it to this goal."
[goals.empty] [goals.empty]
title = "No goals yet" title = "No goals yet"

View File

@ -243,6 +243,15 @@ total_committed = "Total comprometido"
vs_last_month_up = "↑ vs mês anterior" vs_last_month_up = "↑ vs mês anterior"
vs_last_month_down = "↓ vs mês anterior" vs_last_month_down = "↓ vs mês anterior"
[dashboard.waterfall]
title = "Fluxo de caixa este mês"
income = "Rendimento"
living = "Despesas de vida"
goals = "Contribuições para objetivos"
goals_link = "gerir →"
free_cash = "Dinheiro livre"
month_progress = "Progresso do mês"
# ── Transações ──────────────────────────────────────────────────────────────── # ── Transações ────────────────────────────────────────────────────────────────
[transactions] [transactions]
@ -287,6 +296,8 @@ placeholder_amount = "0,00"
label_category = "Categoria" label_category = "Categoria"
label_account = "Conta" label_account = "Conta"
option_no_account = "— nenhuma —" option_no_account = "— nenhuma —"
label_goal = "Financiar um objetivo (opcional)"
option_no_goal = "— não é contribuição para objetivo —"
btn_cancel = "Cancelar" btn_cancel = "Cancelar"
btn_save = "Guardar Transação" btn_save = "Guardar Transação"
error_required = "Por favor, preencha a data, a descrição e um valor positivo." error_required = "Por favor, preencha a data, a descrição e um valor positivo."
@ -338,11 +349,11 @@ tab_planner = "Planeador de Objetivos"
[goals.summary_cards] [goals.summary_cards]
avg_monthly_savings = "Poupança mensal média" avg_monthly_savings = "Poupança mensal média"
last_3_months = "últimos 3 meses" last_3_months = "últimos 3 meses"
disposable_income = "Rendimento disponível" income = "Rendimento"
before_goals = "antes dos objetivos" this_month = "este mês"
reserved_for_goals = "Reservado para objetivos" goal_funded = "Financiado para objetivos"
per_month = "por mês" tagged_transactions = "via transações etiquetadas"
free_to_spend = "Livre para gastar" free_cash = "Dinheiro livre"
after_goals = "após objetivos" after_goals = "após objetivos"
[goals.goal_card] [goals.goal_card]
@ -362,6 +373,9 @@ btn_committed = "✓ Comprometido — clique para cancelar compromisso
btn_commit = "Comprometer com este objetivo" btn_commit = "Comprometer com este objetivo"
btn_remove = "Remover" btn_remove = "Remover"
confirm_remove = "Remover este objetivo?" confirm_remove = "Remover este objetivo?"
btn_fund = "Financiar este objetivo"
funding_history = "Contribuições recentes"
no_funding_yet = "Nenhuma transação etiquetada ainda. Adicione uma transação e associe-a a este objetivo."
[goals.empty] [goals.empty]
title = "Sem objetivos ainda" title = "Sem objetivos ainda"

View File

@ -49,6 +49,7 @@ type Transaction struct {
Description string `bson:"description" json:"description"` Description string `bson:"description" json:"description"`
AmountCents int64 `bson:"amount_cents" json:"amount_cents"` AmountCents int64 `bson:"amount_cents" json:"amount_cents"`
Category string `bson:"category" json:"category"` Category string `bson:"category" json:"category"`
GoalID string `bson:"goal_id,omitempty" json:"goal_id,omitempty"`
BankRef string `bson:"bank_ref,omitempty" json:"bank_ref,omitempty"` BankRef string `bson:"bank_ref,omitempty" json:"bank_ref,omitempty"`
RawCSV string `bson:"raw_csv,omitempty" json:"raw_csv,omitempty"` RawCSV string `bson:"raw_csv,omitempty" json:"raw_csv,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"` CreatedAt time.Time `bson:"created_at" json:"created_at"`
@ -114,10 +115,11 @@ var FixedCategories = map[string]bool{
"Investments": true, "Investments": true,
} }
type RecurringExpense struct { // WaterfallRow is one drilldown entry inside the interactive waterfall.
Category string type WaterfallRow struct {
MonthlyCents int64 Name string
IsGoal bool // true when this entry comes from a committed goal Color string
Cents int64 // always positive (absolute spend or income amount)
} }
type DashboardData struct { type DashboardData struct {
@ -132,23 +134,26 @@ type DashboardData struct {
RecentTxns []Transaction RecentTxns []Transaction
BalanceTrend []BalancePoint BalanceTrend []BalancePoint
// Phase 1 fields
ThisMonthIncome int64 ThisMonthIncome int64
ThisMonthExpense int64 ThisMonthExpense int64
CategoryBudgets map[string]int64 CategoryBudgets map[string]int64
CategoryColors map[string]string CategoryColors map[string]string
MonthProgressPct int
AvailableToSpend int64 // income fixed variable budgets spent so far // Transaction-backed waterfall totals
DisposableIncome int64 // income fixed recurring costs WaterfallIncome int64
MonthProgressPct int // % of month elapsed WaterfallLiving int64
MonthSpentPct int // % of disposable already spent WaterfallGoals int64
WaterfallFreeCash int64
RecurringExpenses []RecurringExpense // Drill-down: sorted category rows + pre-grouped transactions
BankShouldBe int64 IncomeCats []WaterfallRow
SafetyBufferCents int64 LivingCats []WaterfallRow
TotalCommittedCents int64 // sum of all fixed costs + committed goals IncomeCatTxns map[string][]Transaction // category → this-month income txns
LivingCatTxns map[string][]Transaction // category → this-month living txns
GoalFundedThisMonth map[string]int64 // goalID → amount funded this month
SavingsRatePct int // savings / income * 100 this month SavingsRatePct int
LastMonthSavingsRatePct int LastMonthSavingsRatePct int
PortfolioValueCents int64 PortfolioValueCents int64
@ -159,7 +164,7 @@ type DashboardData struct {
NetWorthCents int64 NetWorthCents int64
Alerts []Alert Alerts []Alert
DashGoals []GoalPlan // committed goals for the dashboard widget DashGoals []GoalPlan
} }
type PeriodSummary struct { type PeriodSummary struct {
@ -472,6 +477,7 @@ type GoalPlan struct {
MonthsAtCurrentRate int64 MonthsAtCurrentRate int64
Feasible bool Feasible bool
ProgressPct int64 ProgressPct int64
FundingTxns []Transaction // recent transactions tagged to this goal
} }
type GoalsData struct { type GoalsData struct {
@ -483,10 +489,11 @@ type GoalsData struct {
Tab string // "goals" or "planner" Tab string // "goals" or "planner"
Goals []GoalPlan Goals []GoalPlan
AvgMonthlySavings int64 AvgMonthlySavings int64
DisposableIncome int64 // Waterfall (transaction-backed)
CommittedMonthlyCents int64 // sum of all committed goal contributions WaterfallIncome int64 // gross income this month
RemainingDisposable int64 // disposable after committed goals WaterfallLiving int64 // outflows not tagged to any goal
ConflictWarning string // set when committing would exceed disposable WaterfallGoals int64 // outflows tagged to goals this month
WaterfallFreeCash int64 // income - living - goals
// Planner tab // Planner tab
PlannerType string // "purchase" or "transition" PlannerType string // "purchase" or "transition"
PlanProperties []PropertyView PlanProperties []PropertyView

View File

@ -84,6 +84,14 @@ func SeedExtras(ctx context.Context, store *Store) {
if err := seedGoals(ctx, store, userID); err != nil { if err := seedGoals(ctx, store, userID); err != nil {
slog.Error("seed: goals failed", "err", err) slog.Error("seed: goals failed", "err", err)
} }
goals, _ = store.getGoals(ctx, userID)
}
// goal-tagged transactions (tx-backed progress — idempotent)
if len(goals) > 0 {
if err := seedGoalTransactions(ctx, store, userID, goals); err != nil {
slog.Error("seed: goal transactions failed", "err", err)
}
} }
// properties & loans // properties & loans
@ -152,6 +160,103 @@ func seedGoals(ctx context.Context, store *Store, userID string) error {
return nil return nil
} }
// seedGoalTransactions back-fills goal-tagged transactions so the tx-backed
// SavedCents and waterfall reflect realistic progress. Idempotent — skips if
// any goal-tagged transactions already exist.
func seedGoalTransactions(ctx context.Context, store *Store, userID string, goals []Goal) error {
existing, _ := store.getTransactions(ctx, userID, bson.M{
"goal_id": bson.M{"$exists": true, "$ne": ""},
})
if len(existing) > 0 {
slog.Info("seed: goal transactions already present, skipping")
return nil
}
accounts, _ := store.getAccounts(ctx, userID)
savingsID := ""
checkingID := ""
for _, a := range accounts {
switch a.Type {
case "savings":
savingsID = a.ID
case "checking":
if checkingID == "" {
checkingID = a.ID
}
}
}
if savingsID == "" {
savingsID = checkingID
}
if savingsID == "" && len(accounts) > 0 {
savingsID = accounts[0].ID
}
goalsByName := make(map[string]Goal, len(goals))
for _, g := range goals {
goalsByName[g.Name] = g
}
now := time.Now()
monthStart := func(monthsAgo int, day int) time.Time {
t := time.Date(now.Year(), now.Month(), day, 0, 0, 0, 0, now.Location())
return t.AddDate(0, -monthsAgo, 0)
}
var txns []Transaction
mkTxn := func(date time.Time, desc string, cents int64, goalID string) {
txns = append(txns, Transaction{
ID: bson.NewObjectID().Hex(),
UserID: userID,
AccountID: savingsID,
Date: date,
Description: desc,
AmountCents: -cents, // outflow
Category: "Investments",
GoalID: goalID,
CreatedAt: time.Now(),
})
}
// Emergency fund (3 months) — 7×€300 past months + €300 this month = €2,400
if g, ok := goalsByName["Emergency fund (3 months)"]; ok {
for i := 7; i >= 1; i-- {
mkTxn(monthStart(i, 5), "Emergency fund transfer", 30000, g.ID)
}
mkTxn(monthStart(0, 5), "Emergency fund transfer", 30000, g.ID)
}
// House down payment — 10×€500 past months + €500 this month = €5,500
if g, ok := goalsByName["House down payment"]; ok {
for i := 10; i >= 1; i-- {
mkTxn(monthStart(i, 10), "House down payment savings", 50000, g.ID)
}
mkTxn(monthStart(0, 10), "House down payment savings", 50000, g.ID)
}
// Japan trip — 4×€200 past months + €200 this month = €1,000
if g, ok := goalsByName["Japan trip"]; ok {
for i := 4; i >= 1; i-- {
mkTxn(monthStart(i, 15), "Japan trip savings", 20000, g.ID)
}
mkTxn(monthStart(0, 15), "Japan trip savings", 20000, g.ID)
}
// MacBook Pro — 2×€200 past months + €200 this month = €600
if g, ok := goalsByName["MacBook Pro"]; ok {
for i := 2; i >= 1; i-- {
mkTxn(monthStart(i, 20), "MacBook Pro savings", 20000, g.ID)
}
mkTxn(monthStart(0, 20), "MacBook Pro savings", 20000, g.ID)
}
if len(txns) == 0 {
return nil
}
slog.Info("seed: inserting goal-tagged transactions", "count", len(txns))
return store.createTransactions(ctx, txns)
}
func seedProperty(ctx context.Context, store *Store, userID string) error { func seedProperty(ctx context.Context, store *Store, userID string) error {
now := time.Now() now := time.Now()
propID := bson.NewObjectID().Hex() propID := bson.NewObjectID().Hex()

View File

@ -301,6 +301,62 @@ func (s *Store) deleteGoal(ctx context.Context, id, userID string) error {
return err return err
} }
// getGoalFundedCentsAll aggregates all transactions tagged with a goal_id and returns
// a map of goalID → total funded cents (absolute value of outflows).
func (s *Store) getGoalFundedCentsAll(ctx context.Context, userID string) (map[string]int64, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getGoalFundedCentsAll")
defer span.End()
pipeline := bson.A{
bson.M{"$match": bson.M{
"user_id": userID,
"goal_id": bson.M{"$exists": true, "$ne": ""},
"amount_cents": bson.M{"$lt": 0},
}},
bson.M{"$group": bson.M{
"_id": "$goal_id",
"total": bson.M{"$sum": bson.M{"$abs": "$amount_cents"}},
}},
}
cur, err := s.transactions().Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var rows []struct {
ID string `bson:"_id"`
Total int64 `bson:"total"`
}
if err := cur.All(ctx, &rows); err != nil {
return nil, err
}
result := make(map[string]int64, len(rows))
for _, r := range rows {
result[r.ID] = r.Total
}
return result, nil
}
// getGoalTransactions returns the most recent transactions tagged to a specific goal.
func (s *Store) getGoalTransactions(ctx context.Context, userID, goalID string) ([]Transaction, error) {
ctx, span := mongo.StartSpan(ctx, "Store.getGoalTransactions")
defer span.End()
opts := options.Find().SetSort(bson.M{"date": -1}).SetLimit(5)
cur, err := s.transactions().Find(ctx, bson.M{
"user_id": userID,
"goal_id": goalID,
"amount_cents": bson.M{"$lt": 0},
}, opts)
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var txns []Transaction
if err := cur.All(ctx, &txns); err != nil {
return nil, err
}
return txns, nil
}
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

@ -20,45 +20,206 @@
</div> </div>
{{end}} {{end}}
<!-- HERO: available to spend --> <!-- HERO: interactive cash flow waterfall -->
<div class="card animate-on-scroll" style="margin-bottom:16px; padding:24px 28px;"> <div class="card animate-on-scroll" style="margin-bottom:16px; padding:24px 28px;">
<div style="font-size:12px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:10px; display:flex; align-items:center; gap:8px;"> <div style="font-size:12px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:4px;">
{{$d.T.Get "dashboard.available_to_spend"}} {{$d.T.Get "dashboard.waterfall.title"}}
<span style="font-size:11px; background:var(--bg3); color:var(--text3); padding:2px 8px; border-radius:99px; font-weight:400; text-transform:none; letter-spacing:0;"> </div>
{{$d.T.Get "dashboard.available_formula"}}
<!-- Income row -->
{{if $d.IncomeCats}}
<div class="wf-section">
<button class="wf-row" onclick="wfToggle('income')" aria-expanded="false">
<span class="wf-chevron" id="wf-chev-income"></span>
<span class="wf-row-label">{{$d.T.Get "dashboard.waterfall.income"}}</span>
<span class="wf-row-amt positive">+€{{cents $d.WaterfallIncome}}</span>
</button>
<div class="wf-detail" id="wf-income" style="display:none;">
{{range $i, $row := $d.IncomeCats}}
<div class="wf-cat-section">
<button class="wf-cat-row" onclick="wfToggleCat('ic{{$i}}')">
{{if $row.Color}}<span class="cat-dot" style="background:{{$row.Color}};"></span>{{end}}
<span style="flex:1; font-size:12px;">{{$row.Name}}</span>
<span class="wf-cat-chev" id="wf-cc-ic{{$i}}"></span>
<span style="font-size:12px; font-weight:500; color:var(--green);">+€{{cents $row.Cents}}</span>
</button>
<div class="wf-txn-list" id="wf-ic{{$i}}" style="display:none;">
{{range index $d.IncomeCatTxns $row.Name}}
<div class="wf-txn-row">
<span class="wf-txn-date">{{dateShort .Date}}</span>
<span class="wf-txn-desc">{{.Description}}</span>
<span class="wf-txn-amt positive">+€{{cents .AmountCents}}</span>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
</div>
{{else}}
<div class="wf-row" style="cursor:default;">
<span class="wf-row-label" style="padding-left:20px;">{{$d.T.Get "dashboard.waterfall.income"}}</span>
<span class="wf-row-amt positive">+€{{cents $d.WaterfallIncome}}</span>
</div>
{{end}}
<!-- Living expenses row -->
{{if $d.LivingCats}}
<div class="wf-section">
<button class="wf-row" onclick="wfToggle('living')" aria-expanded="false">
<span class="wf-chevron" id="wf-chev-living"></span>
<span class="wf-row-label">{{$d.T.Get "dashboard.waterfall.living"}}</span>
<span class="wf-row-amt" style="color:var(--red);">−€{{cents $d.WaterfallLiving}}</span>
</button>
<div class="wf-detail" id="wf-living" style="display:none;">
{{range $i, $row := $d.LivingCats}}
<div class="wf-cat-section">
<button class="wf-cat-row" onclick="wfToggleCat('lc{{$i}}')">
{{if $row.Color}}<span class="cat-dot" style="background:{{$row.Color}};"></span>{{end}}
<span style="flex:1; font-size:12px;">{{$row.Name}}</span>
<span class="wf-cat-chev" id="wf-cc-lc{{$i}}"></span>
<span style="font-size:12px; font-weight:500; color:var(--red);">−€{{cents $row.Cents}}</span>
</button>
<div class="wf-txn-list" id="wf-lc{{$i}}" style="display:none;">
{{range index $d.LivingCatTxns $row.Name}}
<div class="wf-txn-row">
<span class="wf-txn-date">{{dateShort .Date}}</span>
<span class="wf-txn-desc">{{.Description}}</span>
<span class="wf-txn-amt" style="color:var(--red);">−€{{cents (abs .AmountCents)}}</span>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
</div>
{{else if gt $d.WaterfallLiving 0}}
<div class="wf-row" style="cursor:default;">
<span class="wf-row-label" style="padding-left:20px;">{{$d.T.Get "dashboard.waterfall.living"}}</span>
<span class="wf-row-amt" style="color:var(--red);">−€{{cents $d.WaterfallLiving}}</span>
</div>
{{end}}
<!-- Goal contributions row -->
{{if gt $d.WaterfallGoals 0}}
<div class="wf-section">
{{if $d.DashGoals}}
<button class="wf-row" onclick="wfToggle('goals')" aria-expanded="false">
<span class="wf-chevron" id="wf-chev-goals"></span>
<span class="wf-row-label">{{$d.T.Get "dashboard.waterfall.goals"}}</span>
<span class="wf-row-amt" style="color:var(--accent);">−€{{cents $d.WaterfallGoals}}</span>
</button>
<div class="wf-detail" id="wf-goals" style="display:none;">
{{range $d.DashGoals}}
{{$funded := index $d.GoalFundedThisMonth .ID}}
{{if gt $funded 0}}
<div class="wf-cat-row" style="cursor:default;">
<span style="font-size:11px;">{{if eq .Type "once"}}🎯{{else if eq .Type "deposit"}}🏠{{else if eq .Type "emergency"}}🛡️{{else}}📈{{end}}</span>
<span style="flex:1; font-size:12px;">{{.Name}}</span>
<a href="/goals" style="font-size:11px; color:var(--accent); text-decoration:none; margin-right:8px;">{{$d.T.Get "dashboard.waterfall.goals_link"}}</a>
<span style="font-size:12px; font-weight:500; color:var(--accent);">−€{{cents $funded}}</span>
</div>
{{end}}
{{end}}
</div>
{{else}}
<div class="wf-row" style="cursor:default;">
<span class="wf-row-label" style="padding-left:20px; display:flex; align-items:center; gap:8px;">
{{$d.T.Get "dashboard.waterfall.goals"}}
<a href="/goals" style="font-size:11px; color:var(--accent); text-decoration:none;">{{$d.T.Get "dashboard.waterfall.goals_link"}}</a>
</span> </span>
<span class="wf-row-amt" style="color:var(--accent);">−€{{cents $d.WaterfallGoals}}</span>
</div>
{{end}}
</div>
{{end}}
<!-- Free cash total -->
<div style="display:flex; justify-content:space-between; align-items:center; padding:14px 0 0 0; margin-top:4px; border-top:1px solid var(--border);">
<span style="font-size:14px; font-weight:600; color:var(--text);">{{$d.T.Get "dashboard.waterfall.free_cash"}}</span>
<div class="animate-counter {{if lt $d.WaterfallFreeCash 0}}negative{{else}}positive{{end}}"
style="font-size:32px; font-weight:500; letter-spacing:-1px; line-height:1;"
data-target="{{$d.WaterfallFreeCash}}" data-prefix="€">€0</div>
</div> </div>
<div style="display:flex; align-items:baseline; gap:20px; flex-wrap:wrap; margin-bottom:16px;"> <!-- Month progress bar -->
<div class="animate-counter {{if lt $d.AvailableToSpend 0}}negative{{else}}positive{{end}}" <div style="margin-top:16px; padding-top:14px; border-top:1px solid var(--border);">
style="font-size:42px; font-weight:500; letter-spacing:-1.5px; line-height:1;" <div style="display:flex; justify-content:space-between; font-size:11px; color:var(--text3); margin-bottom:5px;">
data-target="{{$d.AvailableToSpend}}" data-prefix="€">€0.00</div> <span>{{$d.T.Get "dashboard.waterfall.month_progress"}}</span>
<div style="font-size:13px; color:var(--text2);"> <span>{{$d.MonthProgressPct}}%</span>
of <span style="color:var(--text); font-weight:500;" class="animate-counter" data-target="{{$d.DisposableIncome}}" data-prefix="€">€0.00</span> {{$d.T.Get "dashboard.disposable_label"}}
&nbsp;·&nbsp; <span style="color:var(--text2);">{{$d.MonthSpentPct}}% used</span>
</div> </div>
<div style="background:var(--bg3); border-radius:99px; height:4px; overflow:hidden;">
<div style="height:100%; border-radius:99px; width:{{$d.MonthProgressPct}}%;
background:var(--text3); transition:width 1s ease;"></div>
</div> </div>
<div style="background:var(--bg3); border-radius:99px; height:6px; overflow:hidden; margin-bottom:6px;">
<div style="height:100%; border-radius:99px; width:{{$d.MonthSpentPct}}%;
background:{{if gt $d.MonthSpentPct 90}}var(--red){{else if gt $d.MonthSpentPct 70}}#f59e0b{{else}}var(--green){{end}};
transition:width 1s ease;"></div>
</div>
<div style="display:flex; justify-content:space-between;">
<span style="font-size:11px; color:var(--text3);">Month progress: {{$d.MonthProgressPct}}%</span>
<span style="font-size:11px; color:var(--text3);">Spent: {{$d.MonthSpentPct}}%</span>
</div> </div>
</div> </div>
<style>
.wf-section { border-bottom: 1px solid var(--border); }
.wf-section:last-of-type { border-bottom: none; }
.wf-row {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 10px 0;
background: none; border: none; cursor: pointer; text-align: left;
color: var(--text);
}
.wf-row:hover .wf-row-label { color: var(--text); }
.wf-chevron {
font-size: 14px; color: var(--text3); width: 14px; flex-shrink: 0;
transition: transform 0.18s ease; display: inline-block;
transform-origin: center 45%;
}
.wf-chevron.open { transform: rotate(90deg); }
.wf-row-label { flex: 1; font-size: 13px; color: var(--text2); }
.wf-row-amt { font-size: 15px; font-weight: 600; white-space: nowrap; }
.wf-detail { padding: 4px 0 8px 22px; }
.wf-cat-section { }
.wf-cat-row {
display: flex; align-items: center; gap: 7px;
width: 100%; padding: 6px 0;
background: none; border: none; cursor: pointer; text-align: left;
color: var(--text2);
}
.wf-cat-chev {
font-size: 11px; color: var(--text3); width: 10px;
transition: transform 0.15s ease; display: inline-block;
transform-origin: center 45%;
}
.wf-cat-chev.open { transform: rotate(90deg); }
.cat-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.wf-txn-list { padding: 2px 0 6px 14px; }
.wf-txn-row {
display: flex; align-items: center; gap: 8px;
padding: 5px 0; border-bottom: 1px solid var(--border);
font-size: 12px;
}
.wf-txn-row:last-child { border-bottom: none; }
.wf-txn-date { color: var(--text3); white-space: nowrap; min-width: 44px; }
.wf-txn-desc { flex: 1; color: var(--text2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wf-txn-amt { white-space: nowrap; font-weight: 500; }
</style>
<script>
function wfToggle(id) {
const detail = document.getElementById('wf-' + id);
const chev = document.getElementById('wf-chev-' + id);
const open = detail.style.display !== 'none';
detail.style.display = open ? 'none' : 'block';
if (chev) chev.classList.toggle('open', !open);
}
function wfToggleCat(id) {
const list = document.getElementById('wf-' + id);
const chev = document.getElementById('wf-cc-' + id);
const open = list.style.display !== 'none';
list.style.display = open ? 'none' : 'block';
if (chev) chev.classList.toggle('open', !open);
}
</script>
<!-- 3 diagnostic cards --> <!-- 3 diagnostic cards -->
<div class="grid" style="margin-bottom:16px;"> <div class="grid" style="margin-bottom:16px;">
<div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "dashboard.cards.bank_should_be"}}</h2>
<div class="value animate-counter" data-target="{{$d.BankShouldBe}}" data-prefix="€" style="color:var(--text);">€0.00</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "dashboard.cards.bank_should_be_sub"}}</p>
</div>
<div class="card value-card animate-on-scroll"> <div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "dashboard.cards.savings_rate"}}</h2> <h2>{{$d.T.Get "dashboard.cards.savings_rate"}}</h2>
<div class="value {{if gt $d.SavingsRatePct 0}}positive{{else}}negative{{end}}">{{$d.SavingsRatePct}}%</div> <div class="value {{if gt $d.SavingsRatePct 0}}positive{{else}}negative{{end}}">{{$d.SavingsRatePct}}%</div>
@ -95,45 +256,9 @@
</div> </div>
<!-- Bank math + stocks --> <!-- Stocks + spending breakdown -->
<div class="grid-2" style="margin-bottom:16px;"> <div class="grid-2" style="margin-bottom:16px;">
<div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2>{{$d.T.Get "dashboard.bank_math.section_title"}}</h2>
<span style="font-size:11px; color:var(--text3);">{{$d.T.Get "dashboard.bank_math.section_subtitle"}}</span>
</div>
{{if $d.RecurringExpenses}}
<div style="display:flex; flex-direction:column;">
{{range $d.RecurringExpenses}}
{{$color := index $d.CategoryColors .Category}}
<div style="display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid var(--border);">
<span style="font-size:13px; color:var(--text2); display:flex; align-items:center; gap:7px;">
{{if $color}}<span style="width:7px;height:7px;border-radius:50%;background:{{$color}};display:inline-block;"></span>{{end}}
{{.Category}}
</span>
<span style="font-size:13px; font-weight:500; color:var(--red);"> €{{cents .MonthlyCents}}</span>
</div>
{{end}}
{{if $d.SafetyBufferCents}}
<div style="display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid var(--border);">
<span style="font-size:13px; color:var(--text2);">{{$d.T.Get "dashboard.bank_math.safety_buffer"}}</span>
<span style="font-size:13px; font-weight:500; color:var(--red);"> €{{cents $d.SafetyBufferCents}}</span>
</div>
{{end}}
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
<span style="font-size:13px; font-weight:500; color:var(--text);">{{$d.T.Get "dashboard.bank_math.minimum_recommended"}}</span>
<span class="animate-counter positive" style="font-size:16px; font-weight:600;"
data-target="{{$d.BankShouldBe}}" data-prefix="€">€0.00</span>
</div>
</div>
{{else}}
<div class="empty-state" style="padding:24px;">
<p>{{$d.T.Get "dashboard.bank_math.no_recurring_msg"}}<br>{{$d.T.Get "dashboard.bank_math.no_recurring_sub"}}</p>
</div>
{{end}}
</div>
<div class="card animate-on-scroll"> <div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2>{{$d.T.Get "dashboard.stocks.section_title"}}</h2> <h2>{{$d.T.Get "dashboard.stocks.section_title"}}</h2>
@ -280,39 +405,4 @@
</div> </div>
{{end}} {{end}}
{{if $d.RecurringExpenses}}
<div class="card animate-on-scroll" style="margin-top:16px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2>{{$d.T.Get "dashboard.fixed_costs.section_title"}}</h2>
<span style="font-size:11px; color:var(--text3);">{{$d.T.Get "dashboard.fixed_costs.auto_detected"}}</span>
</div>
<div style="display:flex; flex-direction:column;">
{{range $d.RecurringExpenses}}
{{$color := index $d.CategoryColors .Category}}
<div style="display:flex; align-items:center; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--border);">
<div style="display:flex; align-items:center; gap:10px;">
{{if .IsGoal}}
<span style="width:9px; height:9px; border-radius:50%; background:var(--accent); flex-shrink:0; display:inline-block;"></span>
{{else if $color}}
<span style="width:9px; height:9px; border-radius:50%; background:{{$color}}; flex-shrink:0; display:inline-block;"></span>
{{end}}
<div>
<div style="font-size:13px; font-weight:500; color:var(--text);">{{.Category}}</div>
<div style="font-size:11px; color:var(--text3);">{{if .IsGoal}}{{$d.T.Get "dashboard.fixed_costs.committed_goal"}}{{else}}{{$d.T.Get "dashboard.fixed_costs.recurring_expense"}}{{end}}</div>
</div>
</div>
<div style="text-align:right;">
<div style="font-size:14px; font-weight:600; color:var(--red);"> €{{cents .MonthlyCents}}</div>
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "dashboard.fixed_costs.per_month"}}</div>
</div>
</div>
{{end}}
<div style="display:flex; justify-content:space-between; align-items:center; padding:12px 0 0 0;">
<span style="font-size:13px; font-weight:500; color:var(--text);">{{$d.T.Get "dashboard.fixed_costs.total_committed"}}</span>
<span class="animate-counter" style="font-size:15px; font-weight:600; color:var(--red);"
data-target="{{$d.TotalCommittedCents}}" data-prefix="€">€0</span>
</div>
</div>
</div>
{{end}}
{{end}} {{end}}

View File

@ -22,40 +22,31 @@
{{if eq $d.Tab "goals"}} {{if eq $d.Tab "goals"}}
{{/* ─── GOALS TAB ─────────────────────────────────────────────────────── */}} {{/* ─── GOALS TAB ─────────────────────────────────────────────────────── */}}
{{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 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;"> {{if $d.AvgMonthlySavings}}
<div class="card value-card animate-on-scroll" style="flex:1; min-width:150px;">
<h2>{{$d.T.Get "goals.summary_cards.avg_monthly_savings"}}</h2> <h2>{{$d.T.Get "goals.summary_cards.avg_monthly_savings"}}</h2>
<div class="value positive animate-counter" data-target="{{$d.AvgMonthlySavings}}" data-prefix="€">€0</div> <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;">{{$d.T.Get "goals.summary_cards.last_3_months"}}</p> <p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.last_3_months"}}</p>
</div> </div>
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;"> {{end}}
<h2>{{$d.T.Get "goals.summary_cards.disposable_income"}}</h2> <div class="card value-card animate-on-scroll" style="flex:1; min-width:150px;">
<div class="value animate-counter" data-target="{{$d.DisposableIncome}}" data-prefix="€" style="color:var(--text);">€0</div> <h2>{{$d.T.Get "goals.summary_cards.income"}}</h2>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.before_goals"}}</p> <div class="value positive animate-counter" data-target="{{$d.WaterfallIncome}}" data-prefix="€">€0</div>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.this_month"}}</p>
</div> </div>
{{if $d.CommittedMonthlyCents}} <div class="card value-card animate-on-scroll" style="flex:1; min-width:150px;">
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;"> <h2>{{$d.T.Get "goals.summary_cards.goal_funded"}}</h2>
<h2>{{$d.T.Get "goals.summary_cards.reserved_for_goals"}}</h2> <div class="value animate-counter" style="color:var(--accent);" data-target="{{$d.WaterfallGoals}}" data-prefix="€">€0</div>
<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;">{{$d.T.Get "goals.summary_cards.tagged_transactions"}}</p>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.per_month"}}</p>
</div> </div>
<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:150px;">
<h2>{{$d.T.Get "goals.summary_cards.free_to_spend"}}</h2> <h2>{{$d.T.Get "goals.summary_cards.free_cash"}}</h2>
<div class="value animate-counter {{if lt $d.RemainingDisposable 0}}negative{{else}}positive{{end}}" <div class="value animate-counter {{if lt $d.WaterfallFreeCash 0}}negative{{else}}positive{{end}}"
data-target="{{$d.RemainingDisposable}}" data-prefix="€">€0</div> data-target="{{$d.WaterfallFreeCash}}" data-prefix="€">€0</div>
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.after_goals"}}</p> <p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.after_goals"}}</p>
</div> </div>
{{end}}
</div> </div>
{{end}}
{{if $d.Goals}} {{if $d.Goals}}
<div style="display:flex; flex-direction:column; gap:14px;"> <div style="display:flex; flex-direction:column; gap:14px;">
@ -141,8 +132,9 @@
{{end}} {{end}}
</div> </div>
<!-- commit / uncommit / delete --> <!-- commit / uncommit / delete / fund -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:14px; flex-wrap:wrap; gap:8px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-top:14px; flex-wrap:wrap; gap:8px;">
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<form method="POST" action="/goals"> <form method="POST" action="/goals">
<input type="hidden" name="action" value="{{if .Committed}}uncommit{{else}}commit{{end}}"> <input type="hidden" name="action" value="{{if .Committed}}uncommit{{else}}commit{{end}}">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
@ -154,6 +146,10 @@
<button type="submit" class="btn btn-primary btn-sm">{{$d.T.Get "goals.goal_card.btn_commit"}}</button> <button type="submit" class="btn btn-primary btn-sm">{{$d.T.Get "goals.goal_card.btn_commit"}}</button>
{{end}} {{end}}
</form> </form>
<a href="/transactions?fund_goal={{.ID}}" class="btn btn-outline btn-sm" style="color:var(--accent); border-color:var(--accent)55;">
{{$d.T.Get "goals.goal_card.btn_fund"}}
</a>
</div>
<form method="POST" action="/goals"> <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}}">
@ -161,6 +157,27 @@
onclick="return confirm('{{$d.T.Get "goals.goal_card.confirm_remove"}}')">{{$d.T.Get "goals.goal_card.btn_remove"}}</button> onclick="return confirm('{{$d.T.Get "goals.goal_card.confirm_remove"}}')">{{$d.T.Get "goals.goal_card.btn_remove"}}</button>
</form> </form>
</div> </div>
<!-- funding history -->
<div style="margin-top:14px; padding-top:14px; border-top:1px solid var(--border);">
<div style="font-size:12px; font-weight:600; color:var(--text3); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:8px;">
{{$d.T.Get "goals.goal_card.funding_history"}}
<span style="font-weight:400; color:var(--accent); font-size:11px; margin-left:4px;">€{{cents .SavedCents}} {{$d.T.Get "goals.goal_card.saved_of"}} €{{cents .TargetCents}}</span>
</div>
{{if .FundingTxns}}
<div style="display:flex; flex-direction:column; gap:4px;">
{{range .FundingTxns}}
<div style="display:flex; justify-content:space-between; align-items:center; font-size:12px; padding:5px 0; border-bottom:1px solid var(--border);">
<span style="color:var(--text2);">{{.Date.Format "02 Jan"}}</span>
<span style="flex:1; padding:0 10px; color:var(--text);">{{.Description}}</span>
<span style="color:var(--accent); font-weight:600;">€{{cents (abs .AmountCents)}}</span>
</div>
{{end}}
</div>
{{else}}
<p style="font-size:12px; color:var(--text3); margin:0;">{{$d.T.Get "goals.goal_card.no_funding_yet"}}</p>
{{end}}
</div>
</div> </div>
{{end}} {{end}}
</div> </div>

View File

@ -162,6 +162,17 @@
{{end}} {{end}}
</select> </select>
</div> </div>
{{if $d.Goals}}
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.label_goal"}}</label>
<select id="add-goal">
<option value="">{{$d.T.Get "transactions.modal_add.option_no_goal"}}</option>
{{range $d.Goals}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
{{end}}
<div id="add-error" class="error" style="display:none;"></div> <div id="add-error" class="error" style="display:none;"></div>
@ -214,9 +225,15 @@ function delTxn(id) {
}); });
} }
function openAddModal() { function openAddModal(preselectedGoalID) {
document.getElementById('add-modal').style.display = 'flex'; document.getElementById('add-modal').style.display = 'flex';
document.getElementById('add-date').value = new Date().toISOString().slice(0, 10); document.getElementById('add-date').value = new Date().toISOString().slice(0, 10);
if (preselectedGoalID) {
const goalSel = document.getElementById('add-goal');
if (goalSel) goalSel.value = preselectedGoalID;
const signSel = document.getElementById('add-sign');
if (signSel) signSel.value = '-1'; // goal contributions are expenses
}
document.getElementById('add-desc').focus(); document.getElementById('add-desc').focus();
} }
function closeAddModal() { function closeAddModal() {
@ -230,6 +247,8 @@ function submitAdd() {
const amt = parseFloat(document.getElementById('add-amount').value); const amt = parseFloat(document.getElementById('add-amount').value);
const cat = document.getElementById('add-cat').value; const cat = document.getElementById('add-cat').value;
const acct = document.getElementById('add-account').value; const acct = document.getElementById('add-account').value;
const goalEl = document.getElementById('add-goal');
const goalID = goalEl ? goalEl.value : '';
const errEl = document.getElementById('add-error'); const errEl = document.getElementById('add-error');
if (!date || !desc || isNaN(amt) || amt <= 0) { if (!date || !desc || isNaN(amt) || amt <= 0) {
@ -239,10 +258,13 @@ function submitAdd() {
} }
errEl.style.display = 'none'; errEl.style.display = 'none';
const body = {account_id: acct, date, description: desc, amount_cents: Math.round(amt * 100) * sign, category: cat};
if (goalID) body.goal_id = goalID;
fetch('/api/transactions', { fetch('/api/transactions', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({account_id: acct, date, description: desc, amount_cents: Math.round(amt * 100) * sign, category: cat}) body: JSON.stringify(body)
}).then(r => { }).then(r => {
if (!r.ok) { errEl.textContent = '{{$d.T.Get "transactions.modal_add.error_save_failed"}}'; errEl.style.display = 'block'; return; } if (!r.ok) { errEl.textContent = '{{$d.T.Get "transactions.modal_add.error_save_failed"}}'; errEl.style.display = 'block'; return; }
closeAddModal(); closeAddModal();
@ -250,6 +272,13 @@ function submitAdd() {
}); });
} }
// auto-open modal when redirected from goals page with ?fund_goal=<id>
(function() {
const params = new URLSearchParams(window.location.search);
const fundGoal = params.get('fund_goal');
if (fundGoal) openAddModal(fundGoal);
})();
document.getElementById('add-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddModal(); }); document.getElementById('add-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); });
</script> </script>