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:
parent
ccbb60ace9
commit
5f60d963a0
@ -277,6 +277,8 @@ type storeIface interface {
|
||||
createGoal(ctx context.Context, g *Goal) error
|
||||
updateGoal(ctx context.Context, id, userID string, update bson.M) 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
|
||||
getTickerMappings(ctx context.Context, userID string) ([]TickerMapping, 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()
|
||||
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
lastStart := thisStart.AddDate(0, -1, 0)
|
||||
threeMonthsAgo := thisStart.AddDate(0, -3, 0)
|
||||
|
||||
txns, err := h.store.getTransactions(ctx, a.UserID, bson.M{})
|
||||
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}
|
||||
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 balPoints []BalancePoint
|
||||
balByDate := make(map[string]int64)
|
||||
@ -504,7 +502,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
for _, t := range txns {
|
||||
isThisMonth := !t.Date.Before(thisStart)
|
||||
isLastMonth := !t.Date.Before(lastStart) && t.Date.Before(thisStart)
|
||||
isRecent3 := !t.Date.Before(threeMonthsAgo) && t.Date.Before(thisStart)
|
||||
|
||||
if isThisMonth {
|
||||
thisMonth.TotalCents += t.AmountCents
|
||||
@ -514,15 +511,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
@ -565,97 +553,42 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
lastMonthSavings = 0
|
||||
}
|
||||
|
||||
// detect recurring fixed expenses (average over last 3 months)
|
||||
var recurringExpenses []RecurringExpense
|
||||
totalFixedCents := int64(0)
|
||||
for cat, byMonth := range fixedByMonth {
|
||||
total := int64(0)
|
||||
for _, v := range byMonth {
|
||||
total += v
|
||||
}
|
||||
avg := total / int64(len(byMonth))
|
||||
recurringExpenses = append(recurringExpenses, RecurringExpense{Category: cat, MonthlyCents: avg})
|
||||
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 {
|
||||
// transaction-backed waterfall: split this month's transactions into buckets
|
||||
waterfallLiving := int64(0)
|
||||
waterfallGoals := int64(0)
|
||||
incomeByCat := make(map[string]int64)
|
||||
livingByCat := make(map[string]int64)
|
||||
incomeCatTxns := make(map[string][]Transaction)
|
||||
livingCatTxns := make(map[string][]Transaction)
|
||||
goalFundedThisMonth := make(map[string]int64)
|
||||
for _, t := range txns {
|
||||
if t.Date.Before(thisStart) {
|
||||
continue
|
||||
}
|
||||
remaining := g.TargetCents - g.SavedCents
|
||||
if remaining <= 0 {
|
||||
continue
|
||||
}
|
||||
ml := int64(monthsBetween(now2, g.Deadline))
|
||||
if ml < 1 {
|
||||
ml = 1
|
||||
}
|
||||
monthly := remaining / ml
|
||||
committedGoalsCents += monthly
|
||||
recurringExpenses = append(recurringExpenses, RecurringExpense{
|
||||
Category: g.Name,
|
||||
MonthlyCents: monthly,
|
||||
IsGoal: true,
|
||||
})
|
||||
if t.AmountCents > 0 {
|
||||
incomeByCat[t.Category] += t.AmountCents
|
||||
incomeCatTxns[t.Category] = append(incomeCatTxns[t.Category], t)
|
||||
} else {
|
||||
if t.GoalID != "" {
|
||||
waterfallGoals += -t.AmountCents
|
||||
goalFundedThisMonth[t.GoalID] += -t.AmountCents
|
||||
} else {
|
||||
waterfallLiving += -t.AmountCents
|
||||
livingByCat[t.Category] += -t.AmountCents
|
||||
livingCatTxns[t.Category] = append(livingCatTxns[t.Category], t)
|
||||
}
|
||||
}
|
||||
disposableIncome -= committedGoalsCents
|
||||
totalCommittedCents := totalFixedCents + committedGoalsCents
|
||||
}
|
||||
waterfallFreeCash := thisMonthIncome - waterfallLiving - waterfallGoals
|
||||
|
||||
// variable spend so far this month (non-fixed categories, expenses only)
|
||||
variableSpent := int64(0)
|
||||
for cat, amt := range thisMonth.ByCategory {
|
||||
if !FixedCategories[cat] && amt < 0 {
|
||||
variableSpent += -amt
|
||||
}
|
||||
}
|
||||
|
||||
availableToSpend := disposableIncome - variableSpent
|
||||
if availableToSpend < 0 {
|
||||
availableToSpend = 0
|
||||
}
|
||||
// sort category rows by amount descending for the drill-down display
|
||||
incomeCats := sortWaterfallRows(incomeByCat, catColors)
|
||||
livingCats := sortWaterfallRows(livingByCat, catColors)
|
||||
|
||||
// month progress
|
||||
daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day()
|
||||
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
|
||||
savingsRatePct := 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
|
||||
goalFunds, _ := h.store.getGoalFundedCentsAll(ctx, a.UserID)
|
||||
if allGoals, err2 := h.store.getGoals(ctx, a.UserID); err2 == nil {
|
||||
for _, g := range allGoals {
|
||||
if !g.Committed {
|
||||
continue
|
||||
}
|
||||
g.SavedCents = goalFunds[g.ID]
|
||||
remaining := g.TargetCents - g.SavedCents
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
@ -728,10 +663,6 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ml = 1
|
||||
}
|
||||
monthly := remaining / ml
|
||||
var atRate int64
|
||||
if avgSavingsForGoals := disposableIncome; avgSavingsForGoals > 0 {
|
||||
atRate = remaining / avgSavingsForGoals
|
||||
}
|
||||
pct := int64(0)
|
||||
if g.TargetCents > 0 {
|
||||
pct = g.SavedCents * 100 / g.TargetCents
|
||||
@ -743,8 +674,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
Goal: g,
|
||||
MonthsLeft: ml,
|
||||
MonthlyCents: monthly,
|
||||
MonthsAtCurrentRate: atRate,
|
||||
Feasible: disposableIncome >= monthly,
|
||||
Feasible: waterfallFreeCash >= monthly,
|
||||
ProgressPct: pct,
|
||||
})
|
||||
}
|
||||
@ -773,57 +703,18 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// goal deadline risk alerts
|
||||
if goalList, err := h.store.getGoals(ctx, a.UserID); err == nil {
|
||||
threeAgo := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -3, 0)
|
||||
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 {
|
||||
// goal deadline risk alerts (tx-backed remaining)
|
||||
for _, g := range dashGoals {
|
||||
if !g.Committed || g.SavedCents >= g.TargetCents {
|
||||
continue
|
||||
}
|
||||
ml := int64(monthsBetween(now, g.Deadline))
|
||||
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 {
|
||||
if waterfallFreeCash < g.MonthlyCents {
|
||||
alerts = append(alerts, Alert{
|
||||
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{
|
||||
T: h.t(r),
|
||||
@ -840,14 +731,16 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ThisMonthExpense: thisMonthExpense,
|
||||
CategoryBudgets: catBudgets,
|
||||
CategoryColors: catColors,
|
||||
AvailableToSpend: availableToSpend,
|
||||
DisposableIncome: disposableIncome,
|
||||
WaterfallIncome: thisMonthIncome,
|
||||
WaterfallLiving: waterfallLiving,
|
||||
WaterfallGoals: waterfallGoals,
|
||||
WaterfallFreeCash: waterfallFreeCash,
|
||||
IncomeCats: incomeCats,
|
||||
LivingCats: livingCats,
|
||||
IncomeCatTxns: incomeCatTxns,
|
||||
LivingCatTxns: livingCatTxns,
|
||||
GoalFundedThisMonth: goalFundedThisMonth,
|
||||
MonthProgressPct: monthProgressPct,
|
||||
MonthSpentPct: monthSpentPct,
|
||||
RecurringExpenses: recurringExpenses,
|
||||
BankShouldBe: bankShouldBe,
|
||||
SafetyBufferCents: safetyBuffer,
|
||||
TotalCommittedCents: totalCommittedCents,
|
||||
SavingsRatePct: savingsRatePct,
|
||||
LastMonthSavingsRatePct: lastMonthSavingsRatePct,
|
||||
PortfolioValueCents: portfolioValueCents,
|
||||
@ -902,6 +795,7 @@ func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
cats, _ := h.store.getCategories(ctx, a.UserID)
|
||||
accounts, _ := h.store.getAccounts(ctx, a.UserID)
|
||||
goals, _ := h.store.getGoals(ctx, a.UserID)
|
||||
|
||||
accountNames := make(map[string]string)
|
||||
for _, acc := range accounts {
|
||||
@ -923,6 +817,7 @@ func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) {
|
||||
"Txns": txns,
|
||||
"Categories": cats,
|
||||
"Accounts": accounts,
|
||||
"Goals": goals,
|
||||
"AccountNames": accountNames,
|
||||
"CategoryColors": catColors,
|
||||
"Cat": cat,
|
||||
@ -942,6 +837,7 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
Description string `json:"description"`
|
||||
AmountCents int64 `json:"amount_cents"`
|
||||
Category string `json:"category"`
|
||||
GoalID string `json:"goal_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
@ -961,6 +857,7 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
Description: body.Description,
|
||||
AmountCents: body.AmountCents,
|
||||
Category: body.Category,
|
||||
GoalID: body.GoalID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@ -1270,6 +1167,7 @@ func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
GoalID string `json:"goal_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
@ -1283,6 +1181,9 @@ func (h *Handler) UpdateTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
if 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 {
|
||||
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
|
||||
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)
|
||||
thisStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
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 {
|
||||
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
|
||||
if !t.Date.Before(threeMonthsAgo) && t.Date.Before(thisStart) {
|
||||
k := monthKey{t.Date.Year(), int(t.Date.Month())}
|
||||
monthlySavings[k] += t.AmountCents
|
||||
}
|
||||
}
|
||||
var totalSavings int64
|
||||
var posMonths int64
|
||||
for _, s := range monthlySavings {
|
||||
if s > 0 {
|
||||
totalSavings += s
|
||||
posMonths++
|
||||
}
|
||||
}
|
||||
avgMonthlySavings := int64(0)
|
||||
if len(monthlySavings) > 0 {
|
||||
avgMonthlySavings = totalSavings / int64(len(monthlySavings))
|
||||
if posMonths > 0 {
|
||||
avgMonthlySavings = totalSavings / posMonths
|
||||
}
|
||||
|
||||
// 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)
|
||||
// transaction-backed waterfall for this month
|
||||
waterfallIncome := int64(0)
|
||||
waterfallLiving := int64(0)
|
||||
waterfallGoals := 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
|
||||
waterfallIncome += t.AmountCents
|
||||
} else {
|
||||
if t.GoalID != "" {
|
||||
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
|
||||
for _, g := range goals {
|
||||
g.SavedCents = goalFunds[g.ID]
|
||||
remaining := g.TargetCents - g.SavedCents
|
||||
if 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{
|
||||
Goal: g,
|
||||
MonthsLeft: monthsLeft,
|
||||
MonthlyCents: monthlyCents,
|
||||
ImpactOnDisposable: disposable - monthlyCents,
|
||||
ImpactOnDisposable: waterfallIncome - waterfallLiving - monthlyCents,
|
||||
MonthsAtCurrentRate: monthsAtRate,
|
||||
Feasible: avgMonthlySavings >= monthlyCents,
|
||||
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{
|
||||
T: h.t(r),
|
||||
UserID: a.UserID,
|
||||
@ -2013,10 +1904,10 @@ func (h *Handler) Goals(w http.ResponseWriter, r *http.Request) {
|
||||
Tab: r.URL.Query().Get("tab"),
|
||||
Goals: plans,
|
||||
AvgMonthlySavings: avgMonthlySavings,
|
||||
DisposableIncome: disposable,
|
||||
CommittedMonthlyCents: committedTotal,
|
||||
RemainingDisposable: remainingDisposable,
|
||||
ConflictWarning: conflictWarning,
|
||||
WaterfallIncome: waterfallIncome,
|
||||
WaterfallLiving: waterfallLiving,
|
||||
WaterfallGoals: waterfallGoals,
|
||||
WaterfallFreeCash: waterfallFreeCash,
|
||||
}
|
||||
if data.Tab == "" {
|
||||
data.Tab = "goals"
|
||||
@ -2912,6 +2803,16 @@ func sortStrings(s []string) {
|
||||
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 {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
|
||||
@ -243,6 +243,15 @@ total_committed = "Total committed"
|
||||
vs_last_month_up = "↑ 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]
|
||||
@ -287,6 +296,8 @@ placeholder_amount = "0.00"
|
||||
label_category = "Category"
|
||||
label_account = "Account"
|
||||
option_no_account = "— none —"
|
||||
label_goal = "Fund a goal (optional)"
|
||||
option_no_goal = "— not a goal contribution —"
|
||||
btn_cancel = "Cancel"
|
||||
btn_save = "Save Transaction"
|
||||
error_required = "Please fill in date, description, and a positive amount."
|
||||
@ -338,11 +349,11 @@ tab_planner = "Goal Planner"
|
||||
[goals.summary_cards]
|
||||
avg_monthly_savings = "Avg monthly savings"
|
||||
last_3_months = "last 3 months"
|
||||
disposable_income = "Disposable income"
|
||||
before_goals = "before goals"
|
||||
reserved_for_goals = "Reserved for goals"
|
||||
per_month = "per month"
|
||||
free_to_spend = "Free to spend"
|
||||
income = "Income"
|
||||
this_month = "this month"
|
||||
goal_funded = "Funded to goals"
|
||||
tagged_transactions = "via tagged transactions"
|
||||
free_cash = "Free cash"
|
||||
after_goals = "after goals"
|
||||
|
||||
[goals.goal_card]
|
||||
@ -362,6 +373,9 @@ btn_committed = "✓ Committed — click to uncommit"
|
||||
btn_commit = "Commit to this goal"
|
||||
btn_remove = "Remove"
|
||||
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]
|
||||
title = "No goals yet"
|
||||
|
||||
@ -243,6 +243,15 @@ total_committed = "Total comprometido"
|
||||
vs_last_month_up = "↑ 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
[transactions]
|
||||
@ -287,6 +296,8 @@ placeholder_amount = "0,00"
|
||||
label_category = "Categoria"
|
||||
label_account = "Conta"
|
||||
option_no_account = "— nenhuma —"
|
||||
label_goal = "Financiar um objetivo (opcional)"
|
||||
option_no_goal = "— não é contribuição para objetivo —"
|
||||
btn_cancel = "Cancelar"
|
||||
btn_save = "Guardar Transação"
|
||||
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]
|
||||
avg_monthly_savings = "Poupança mensal média"
|
||||
last_3_months = "últimos 3 meses"
|
||||
disposable_income = "Rendimento disponível"
|
||||
before_goals = "antes dos objetivos"
|
||||
reserved_for_goals = "Reservado para objetivos"
|
||||
per_month = "por mês"
|
||||
free_to_spend = "Livre para gastar"
|
||||
income = "Rendimento"
|
||||
this_month = "este mês"
|
||||
goal_funded = "Financiado para objetivos"
|
||||
tagged_transactions = "via transações etiquetadas"
|
||||
free_cash = "Dinheiro livre"
|
||||
after_goals = "após objetivos"
|
||||
|
||||
[goals.goal_card]
|
||||
@ -362,6 +373,9 @@ btn_committed = "✓ Comprometido — clique para cancelar compromisso
|
||||
btn_commit = "Comprometer com este objetivo"
|
||||
btn_remove = "Remover"
|
||||
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]
|
||||
title = "Sem objetivos ainda"
|
||||
|
||||
@ -49,6 +49,7 @@ type Transaction struct {
|
||||
Description string `bson:"description" json:"description"`
|
||||
AmountCents int64 `bson:"amount_cents" json:"amount_cents"`
|
||||
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"`
|
||||
RawCSV string `bson:"raw_csv,omitempty" json:"raw_csv,omitempty"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
@ -114,10 +115,11 @@ var FixedCategories = map[string]bool{
|
||||
"Investments": true,
|
||||
}
|
||||
|
||||
type RecurringExpense struct {
|
||||
Category string
|
||||
MonthlyCents int64
|
||||
IsGoal bool // true when this entry comes from a committed goal
|
||||
// WaterfallRow is one drilldown entry inside the interactive waterfall.
|
||||
type WaterfallRow struct {
|
||||
Name string
|
||||
Color string
|
||||
Cents int64 // always positive (absolute spend or income amount)
|
||||
}
|
||||
|
||||
type DashboardData struct {
|
||||
@ -132,23 +134,26 @@ type DashboardData struct {
|
||||
RecentTxns []Transaction
|
||||
BalanceTrend []BalancePoint
|
||||
|
||||
// Phase 1 fields
|
||||
ThisMonthIncome int64
|
||||
ThisMonthExpense int64
|
||||
CategoryBudgets map[string]int64
|
||||
CategoryColors map[string]string
|
||||
MonthProgressPct int
|
||||
|
||||
AvailableToSpend int64 // income − fixed − variable budgets spent so far
|
||||
DisposableIncome int64 // income − fixed recurring costs
|
||||
MonthProgressPct int // % of month elapsed
|
||||
MonthSpentPct int // % of disposable already spent
|
||||
// Transaction-backed waterfall totals
|
||||
WaterfallIncome int64
|
||||
WaterfallLiving int64
|
||||
WaterfallGoals int64
|
||||
WaterfallFreeCash int64
|
||||
|
||||
RecurringExpenses []RecurringExpense
|
||||
BankShouldBe int64
|
||||
SafetyBufferCents int64
|
||||
TotalCommittedCents int64 // sum of all fixed costs + committed goals
|
||||
// Drill-down: sorted category rows + pre-grouped transactions
|
||||
IncomeCats []WaterfallRow
|
||||
LivingCats []WaterfallRow
|
||||
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
|
||||
|
||||
PortfolioValueCents int64
|
||||
@ -159,7 +164,7 @@ type DashboardData struct {
|
||||
NetWorthCents int64
|
||||
|
||||
Alerts []Alert
|
||||
DashGoals []GoalPlan // committed goals for the dashboard widget
|
||||
DashGoals []GoalPlan
|
||||
}
|
||||
|
||||
type PeriodSummary struct {
|
||||
@ -472,6 +477,7 @@ type GoalPlan struct {
|
||||
MonthsAtCurrentRate int64
|
||||
Feasible bool
|
||||
ProgressPct int64
|
||||
FundingTxns []Transaction // recent transactions tagged to this goal
|
||||
}
|
||||
|
||||
type GoalsData struct {
|
||||
@ -483,10 +489,11 @@ type GoalsData struct {
|
||||
Tab string // "goals" or "planner"
|
||||
Goals []GoalPlan
|
||||
AvgMonthlySavings int64
|
||||
DisposableIncome int64
|
||||
CommittedMonthlyCents int64 // sum of all committed goal contributions
|
||||
RemainingDisposable int64 // disposable after committed goals
|
||||
ConflictWarning string // set when committing would exceed disposable
|
||||
// Waterfall (transaction-backed)
|
||||
WaterfallIncome int64 // gross income this month
|
||||
WaterfallLiving int64 // outflows not tagged to any goal
|
||||
WaterfallGoals int64 // outflows tagged to goals this month
|
||||
WaterfallFreeCash int64 // income - living - goals
|
||||
// Planner tab
|
||||
PlannerType string // "purchase" or "transition"
|
||||
PlanProperties []PropertyView
|
||||
|
||||
@ -84,6 +84,14 @@ func SeedExtras(ctx context.Context, store *Store) {
|
||||
if err := seedGoals(ctx, store, userID); err != nil {
|
||||
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
|
||||
@ -152,6 +160,103 @@ func seedGoals(ctx context.Context, store *Store, userID string) error {
|
||||
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 {
|
||||
now := time.Now()
|
||||
propID := bson.NewObjectID().Hex()
|
||||
|
||||
@ -301,6 +301,62 @@ func (s *Store) deleteGoal(ctx context.Context, id, userID string) error {
|
||||
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 {
|
||||
ctx, span := mongo.StartSpan(ctx, "Store.deletePermission")
|
||||
defer span.End()
|
||||
|
||||
@ -20,45 +20,206 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- HERO: available to spend -->
|
||||
<!-- HERO: interactive cash flow waterfall -->
|
||||
<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;">
|
||||
{{$d.T.Get "dashboard.available_to_spend"}}
|
||||
<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;">
|
||||
{{$d.T.Get "dashboard.available_formula"}}
|
||||
<div style="font-size:12px; color:var(--text2); text-transform:uppercase; letter-spacing:.5px; margin-bottom:4px;">
|
||||
{{$d.T.Get "dashboard.waterfall.title"}}
|
||||
</div>
|
||||
|
||||
<!-- 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 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 style="display:flex; align-items:baseline; gap:20px; flex-wrap:wrap; margin-bottom:16px;">
|
||||
<div class="animate-counter {{if lt $d.AvailableToSpend 0}}negative{{else}}positive{{end}}"
|
||||
style="font-size:42px; font-weight:500; letter-spacing:-1.5px; line-height:1;"
|
||||
data-target="{{$d.AvailableToSpend}}" data-prefix="€">€0.00</div>
|
||||
<div style="font-size:13px; color:var(--text2);">
|
||||
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"}}
|
||||
· <span style="color:var(--text2);">{{$d.MonthSpentPct}}% used</span>
|
||||
<!-- Month progress bar -->
|
||||
<div style="margin-top:16px; padding-top:14px; border-top:1px solid var(--border);">
|
||||
<div style="display:flex; justify-content:space-between; font-size:11px; color:var(--text3); margin-bottom:5px;">
|
||||
<span>{{$d.T.Get "dashboard.waterfall.month_progress"}}</span>
|
||||
<span>{{$d.MonthProgressPct}}%</span>
|
||||
</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 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>
|
||||
|
||||
<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 -->
|
||||
<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">
|
||||
<h2>{{$d.T.Get "dashboard.cards.savings_rate"}}</h2>
|
||||
<div class="value {{if gt $d.SavingsRatePct 0}}positive{{else}}negative{{end}}">{{$d.SavingsRatePct}}%</div>
|
||||
@ -95,45 +256,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Bank math + stocks -->
|
||||
<!-- Stocks + spending breakdown -->
|
||||
<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 style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||
<h2>{{$d.T.Get "dashboard.stocks.section_title"}}</h2>
|
||||
@ -280,39 +405,4 @@
|
||||
</div>
|
||||
{{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}}
|
||||
|
||||
@ -22,40 +22,31 @@
|
||||
{{if eq $d.Tab "goals"}}
|
||||
{{/* ─── 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 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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
||||
<h2>{{$d.T.Get "goals.summary_cards.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;">{{$d.T.Get "goals.summary_cards.before_goals"}}</p>
|
||||
{{end}}
|
||||
<div class="card value-card animate-on-scroll" style="flex:1; min-width:150px;">
|
||||
<h2>{{$d.T.Get "goals.summary_cards.income"}}</h2>
|
||||
<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>
|
||||
{{if $d.CommittedMonthlyCents}}
|
||||
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
||||
<h2>{{$d.T.Get "goals.summary_cards.reserved_for_goals"}}</h2>
|
||||
<div class="value negative animate-counter" data-target="{{$d.CommittedMonthlyCents}}" data-prefix="€">€0</div>
|
||||
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.per_month"}}</p>
|
||||
<div class="card value-card animate-on-scroll" style="flex:1; min-width:150px;">
|
||||
<h2>{{$d.T.Get "goals.summary_cards.goal_funded"}}</h2>
|
||||
<div class="value animate-counter" style="color:var(--accent);" data-target="{{$d.WaterfallGoals}}" data-prefix="€">€0</div>
|
||||
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.tagged_transactions"}}</p>
|
||||
</div>
|
||||
<div class="card value-card animate-on-scroll" style="flex:1; min-width:160px;">
|
||||
<h2>{{$d.T.Get "goals.summary_cards.free_to_spend"}}</h2>
|
||||
<div class="value animate-counter {{if lt $d.RemainingDisposable 0}}negative{{else}}positive{{end}}"
|
||||
data-target="{{$d.RemainingDisposable}}" data-prefix="€">€0</div>
|
||||
<div class="card value-card animate-on-scroll" style="flex:1; min-width:150px;">
|
||||
<h2>{{$d.T.Get "goals.summary_cards.free_cash"}}</h2>
|
||||
<div class="value animate-counter {{if lt $d.WaterfallFreeCash 0}}negative{{else}}positive{{end}}"
|
||||
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>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if $d.Goals}}
|
||||
<div style="display:flex; flex-direction:column; gap:14px;">
|
||||
@ -141,8 +132,9 @@
|
||||
{{end}}
|
||||
</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; gap:8px; flex-wrap:wrap;">
|
||||
<form method="POST" action="/goals">
|
||||
<input type="hidden" name="action" value="{{if .Committed}}uncommit{{else}}commit{{end}}">
|
||||
<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>
|
||||
{{end}}
|
||||
</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">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<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>
|
||||
</form>
|
||||
</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>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@ -162,6 +162,17 @@
|
||||
{{end}}
|
||||
</select>
|
||||
</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>
|
||||
|
||||
@ -214,9 +225,15 @@ function delTxn(id) {
|
||||
});
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
function openAddModal(preselectedGoalID) {
|
||||
document.getElementById('add-modal').style.display = 'flex';
|
||||
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();
|
||||
}
|
||||
function closeAddModal() {
|
||||
@ -230,6 +247,8 @@ function submitAdd() {
|
||||
const amt = parseFloat(document.getElementById('add-amount').value);
|
||||
const cat = document.getElementById('add-cat').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');
|
||||
|
||||
if (!date || !desc || isNaN(amt) || amt <= 0) {
|
||||
@ -239,10 +258,13 @@ function submitAdd() {
|
||||
}
|
||||
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', {
|
||||
method: 'POST',
|
||||
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 => {
|
||||
if (!r.ok) { errEl.textContent = '{{$d.T.Get "transactions.modal_add.error_save_failed"}}'; errEl.style.display = 'block'; return; }
|
||||
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.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); });
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user