feat(finance): interactive waterfall + goal auto-tag + free cash prompt

- Waterfall now drills down: click Income/Living/Goals to expand
  category breakdown, click a category to see its transactions
- Goal contributions are now transaction-backed (GoalID on Transaction,
  SavedCents derived from MongoDB aggregation)
- Dashboard goals widget shows this-month funding status per goal
- Goals page lists funding history transactions per goal
- Transactions modal accepts a goal pre-selection (?fund_goal=<id>)
- Categories can auto-tag a linked goal on expense creation
- Settings → categories shows linked goal column and edit modal
- Free cash "what now?" section lists underfunded committed goals
  with shortfall and Fund → links; shows success state when all met
- i18n: full EN/PT coverage for all new keys
- Seed data includes goal-tagged transactions so progress is non-zero
- Bug fixes: ImpactOnDisposable double-subtraction, avgMonthlySavings
  denominator using only positive-savings months, cross-year month key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-19 22:35:29 +01:00
parent 4cfe80e3d5
commit e93cb38756
7 changed files with 132 additions and 28 deletions

View File

@ -849,6 +849,19 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
date = time.Now() date = time.Now()
} }
// Auto-tag: if no explicit goal_id but the category has one linked, inherit it.
goalID := body.GoalID
if goalID == "" && body.Category != "" && body.AmountCents < 0 {
if cats, err2 := h.store.getCategories(ctx, a.UserID); err2 == nil {
for _, c := range cats {
if c.Name == body.Category && c.GoalID != "" {
goalID = c.GoalID
break
}
}
}
}
txn := Transaction{ txn := Transaction{
ID: bson.NewObjectID().Hex(), ID: bson.NewObjectID().Hex(),
UserID: a.UserID, UserID: a.UserID,
@ -857,7 +870,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, GoalID: goalID,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
@ -1300,6 +1313,7 @@ func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"` Name string `json:"name"`
Color string `json:"color"` Color string `json:"color"`
BudgetCents int64 `json:"budget_cents"` BudgetCents int64 `json:"budget_cents"`
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)
@ -1311,6 +1325,7 @@ func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) {
Name: body.Name, Name: body.Name,
Color: body.Color, Color: body.Color,
BudgetCents: body.BudgetCents, BudgetCents: body.BudgetCents,
GoalID: body.GoalID,
} }
if err := h.store.updateCategory(r.Context(), cat); err != nil { if err := h.store.updateCategory(r.Context(), cat); err != nil {
slog.Error("update category", "err", err) slog.Error("update category", "err", err)
@ -2710,16 +2725,24 @@ func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
accounts, _ := h.store.getAccounts(ctx, a.UserID) accounts, _ := h.store.getAccounts(ctx, a.UserID)
categories, _ := h.store.getCategories(ctx, a.UserID) categories, _ := h.store.getCategories(ctx, a.UserID)
goals, _ := h.store.getGoals(ctx, a.UserID)
goalNameByID := make(map[string]string, len(goals))
for _, g := range goals {
goalNameByID[g.ID] = g.Name
}
render(w, settingsTmpl, &SettingsData{ render(w, settingsTmpl, &SettingsData{
T: h.t(r), T: h.t(r),
UserID: a.UserID, UserID: a.UserID,
Email: a.Email, Email: a.Email,
Title: "Settings", Title: "Settings",
Route: "settings", Route: "settings",
Tab: tab, Tab: tab,
Accounts: accounts, Accounts: accounts,
Categories: categories, Categories: categories,
Goals: goals,
GoalNameByID: goalNameByID,
}) })
} }

View File

@ -258,6 +258,9 @@ goals = "Goal contributions"
goals_link = "manage →" goals_link = "manage →"
free_cash = "Free cash" free_cash = "Free cash"
month_progress = "Month progress" month_progress = "Month progress"
what_now = "What now?"
fund_link = "Fund"
all_funded = "All committed goals funded this month ✓"
# ── Transactions ───────────────────────────────────────────────────────────── # ── Transactions ─────────────────────────────────────────────────────────────
@ -830,6 +833,7 @@ placeholder_budget = "Monthly budget (cents)"
btn_add = "Add" btn_add = "Add"
col_category = "Category" col_category = "Category"
col_budget = "Monthly Budget" col_budget = "Monthly Budget"
col_goal = "Auto-tags goal"
btn_delete = "Delete" btn_delete = "Delete"
empty_msg = "No categories yet — add one above." empty_msg = "No categories yet — add one above."
confirm_delete = "Delete this category?" confirm_delete = "Delete this category?"
@ -838,6 +842,9 @@ confirm_delete = "Delete this category?"
title = "Edit Category" title = "Edit Category"
placeholder_name = "Name" placeholder_name = "Name"
placeholder_budget = "Budget (cents)" placeholder_budget = "Budget (cents)"
label_goal = "Auto-tag goal"
option_no_goal = "— none —"
goal_hint = "Expenses in this category will automatically fund the selected goal."
btn_cancel = "Cancel" btn_cancel = "Cancel"
btn_save = "Save" btn_save = "Save"

View File

@ -258,6 +258,9 @@ goals = "Contribuições para objetivos"
goals_link = "gerir →" goals_link = "gerir →"
free_cash = "Dinheiro livre" free_cash = "Dinheiro livre"
month_progress = "Progresso do mês" month_progress = "Progresso do mês"
what_now = "E agora?"
fund_link = "Financiar"
all_funded = "Todos os objetivos comprometidos financiados este mês ✓"
# ── Transações ──────────────────────────────────────────────────────────────── # ── Transações ────────────────────────────────────────────────────────────────
@ -830,6 +833,7 @@ placeholder_budget = "Orçamento mensal (cêntimos)"
btn_add = "Adicionar" btn_add = "Adicionar"
col_category = "Categoria" col_category = "Categoria"
col_budget = "Orçamento Mensal" col_budget = "Orçamento Mensal"
col_goal = "Etiqueta automática"
btn_delete = "Eliminar" btn_delete = "Eliminar"
empty_msg = "Sem categorias ainda — adicione uma acima." empty_msg = "Sem categorias ainda — adicione uma acima."
confirm_delete = "Eliminar esta categoria?" confirm_delete = "Eliminar esta categoria?"
@ -838,6 +842,9 @@ confirm_delete = "Eliminar esta categoria?"
title = "Editar Categoria" title = "Editar Categoria"
placeholder_name = "Nome" placeholder_name = "Nome"
placeholder_budget = "Orçamento (cêntimos)" placeholder_budget = "Orçamento (cêntimos)"
label_goal = "Etiquetar objetivo automaticamente"
option_no_goal = "— nenhum —"
goal_hint = "As despesas nesta categoria irão financiar automaticamente o objetivo selecionado."
btn_cancel = "Cancelar" btn_cancel = "Cancelar"
btn_save = "Guardar" btn_save = "Guardar"

View File

@ -34,11 +34,13 @@ type Account struct {
} }
type Category struct { type Category struct {
ID string `bson:"_id" json:"id"` ID string `bson:"_id" json:"id"`
UserID string `bson:"user_id" json:"user_id"` UserID string `bson:"user_id" json:"user_id"`
Name string `bson:"name" json:"name"` Name string `bson:"name" json:"name"`
Color string `bson:"color" json:"color"` Color string `bson:"color" json:"color"`
BudgetCents int64 `bson:"budget_cents" json:"budget_cents"` BudgetCents int64 `bson:"budget_cents" json:"budget_cents"`
// GoalID, when set, auto-tags transactions in this category to the linked goal.
GoalID string `bson:"goal_id,omitempty" json:"goal_id,omitempty"`
} }
type Transaction struct { type Transaction struct {
@ -322,6 +324,8 @@ type SettingsData struct {
Tab string // "accounts" | "categories" Tab string // "accounts" | "categories"
Accounts []Account Accounts []Account
Categories []Category Categories []Category
Goals []Goal // for category → goal linking dropdown
GoalNameByID map[string]string // goalID → name, for table display
} }
type HouseholdData struct { type HouseholdData struct {

View File

@ -103,11 +103,13 @@ func (s *Store) createCategory(ctx context.Context, c *Category) error {
func (s *Store) updateCategory(ctx context.Context, c *Category) error { func (s *Store) updateCategory(ctx context.Context, c *Category) error {
ctx, span := mongo.StartSpan(ctx, "Store.updateCategory") ctx, span := mongo.StartSpan(ctx, "Store.updateCategory")
defer span.End() defer span.End()
_, err := s.categories().UpdateOne(ctx, bson.M{"_id": c.ID, "user_id": c.UserID}, bson.M{"$set": bson.M{ update := bson.M{
"name": c.Name, "name": c.Name,
"color": c.Color, "color": c.Color,
"budget_cents": c.BudgetCents, "budget_cents": c.BudgetCents,
}}) "goal_id": c.GoalID, // "" clears the link
}
_, err := s.categories().UpdateOne(ctx, bson.M{"_id": c.ID, "user_id": c.UserID}, bson.M{"$set": update})
return err return err
} }

View File

@ -153,6 +153,29 @@
background:var(--text3); transition:width 1s ease;"></div> background:var(--text3); transition:width 1s ease;"></div>
</div> </div>
</div> </div>
<!-- What now? prompt — underfunded committed goals -->
{{if $d.DashGoals}}
<div style="margin-top:16px; padding-top:14px; border-top:1px solid var(--border);">
<div style="font-size:11px; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:0.06em; margin-bottom:8px;">{{$d.T.Get "dashboard.waterfall.what_now"}}</div>
<div id="wf-what-now-list">
{{range $d.DashGoals}}
{{if .Committed}}
{{$funded := index $d.GoalFundedThisMonth .ID}}
{{$needed := .MonthlyCents}}
{{if lt $funded $needed}}
{{$shortfall := sub $needed $funded}}
<div style="display:flex; justify-content:space-between; align-items:center; padding:5px 0; font-size:13px;">
<span style="color:var(--text2);">{{.Name}} <span style="color:var(--red);">−€{{cents $shortfall}}</span></span>
<a href="/transactions?fund_goal={{.ID}}" style="font-size:12px; color:var(--accent); text-decoration:none; font-weight:600;">{{$d.T.Get "dashboard.waterfall.fund_link"}} →</a>
</div>
{{end}}
{{end}}
{{end}}
</div>
<div id="wf-all-funded" style="display:none; font-size:13px; color:var(--green);">{{$d.T.Get "dashboard.waterfall.all_funded"}}</div>
</div>
{{end}}
</div> </div>
<style> <style>
@ -215,6 +238,11 @@ function wfToggleCat(id) {
list.style.display = open ? 'none' : 'block'; list.style.display = open ? 'none' : 'block';
if (chev) chev.classList.toggle('open', !open); if (chev) chev.classList.toggle('open', !open);
} }
(function() {
const list = document.getElementById('wf-what-now-list');
const allFunded = document.getElementById('wf-all-funded');
if (list && allFunded && !list.children.length) allFunded.style.display = '';
})();
</script> </script>
<!-- 3 diagnostic cards --> <!-- 3 diagnostic cards -->

View File

@ -87,7 +87,12 @@ tr:last-child td { border-bottom:none; }
<div class="s-card"> <div class="s-card">
{{if $d.Categories}} {{if $d.Categories}}
<table> <table>
<thead><tr><th>{{$d.T.Get "settings.categories.col_category"}}</th><th style="text-align:right;">{{$d.T.Get "settings.categories.col_budget"}}</th><th></th></tr></thead> <thead><tr>
<th>{{$d.T.Get "settings.categories.col_category"}}</th>
<th style="text-align:right;">{{$d.T.Get "settings.categories.col_budget"}}</th>
{{if $d.Goals}}<th style="text-align:left; padding-left:12px; font-size:12px;">{{$d.T.Get "settings.categories.col_goal"}}</th>{{end}}
<th></th>
</tr></thead>
<tbody> <tbody>
{{range $d.Categories}} {{range $d.Categories}}
<tr id="cat-row-{{.ID}}"> <tr id="cat-row-{{.ID}}">
@ -101,9 +106,19 @@ tr:last-child td { border-bottom:none; }
{{else}} {{else}}
<span id="cat-budget-{{.ID}}" style="color:var(--muted);"></span> <span id="cat-budget-{{.ID}}" style="color:var(--muted);"></span>
{{end}} {{end}}
<button onclick="editCat('{{.ID}}','{{.Name}}',{{.BudgetCents}},'{{.Color}}')" <button onclick="editCat('{{.ID}}','{{.Name}}',{{.BudgetCents}},'{{.Color}}','{{.GoalID}}')"
style="margin-left:8px; background:none; border:none; cursor:pointer; color:var(--muted); font-size:0.85rem;">✎</button> style="margin-left:8px; background:none; border:none; cursor:pointer; color:var(--muted); font-size:0.85rem;">✎</button>
</td> </td>
{{if $d.Goals}}
<td style="padding-left:12px;">
{{$goalName := index $d.GoalNameByID .GoalID}}
{{if $goalName}}
<span id="cat-goal-{{.ID}}" style="font-size:12px; color:var(--accent);">⚡ {{$goalName}}</span>
{{else}}
<span id="cat-goal-{{.ID}}" style="font-size:12px; color:var(--muted);"></span>
{{end}}
</td>
{{end}}
<td style="text-align:right;"> <td style="text-align:right;">
<button class="btn btn-danger" onclick="deleteCat('{{.ID}}')">{{$d.T.Get "settings.categories.btn_delete"}}</button> <button class="btn btn-danger" onclick="deleteCat('{{.ID}}')">{{$d.T.Get "settings.categories.btn_delete"}}</button>
</td> </td>
@ -129,6 +144,18 @@ tr:last-child td { border-bottom:none; }
<input id="edit-cat-name" type="text" placeholder="{{$d.T.Get "settings.categories.modal_edit.placeholder_name"}}" style="padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text);"> <input id="edit-cat-name" type="text" placeholder="{{$d.T.Get "settings.categories.modal_edit.placeholder_name"}}" style="padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text);">
<input id="edit-cat-budget" type="number" placeholder="{{$d.T.Get "settings.categories.modal_edit.placeholder_budget"}}" min="0" style="padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text);"> <input id="edit-cat-budget" type="number" placeholder="{{$d.T.Get "settings.categories.modal_edit.placeholder_budget"}}" min="0" style="padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text);">
<input id="edit-cat-color" type="color" style="width:100%; height:38px; padding:4px; border:1px solid var(--border); border-radius:8px; cursor:pointer;"> <input id="edit-cat-color" type="color" style="width:100%; height:38px; padding:4px; border:1px solid var(--border); border-radius:8px; cursor:pointer;">
{{if $d.Goals}}
<div>
<label style="font-size:12px; color:var(--muted); display:block; margin-bottom:4px;">{{$d.T.Get "settings.categories.modal_edit.label_goal"}}</label>
<select id="edit-cat-goal" style="width:100%; padding:8px 12px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text);">
<option value="">{{$d.T.Get "settings.categories.modal_edit.option_no_goal"}}</option>
{{range $d.Goals}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
<p style="font-size:11px; color:var(--muted); margin-top:4px;">{{$d.T.Get "settings.categories.modal_edit.goal_hint"}}</p>
</div>
{{end}}
</div> </div>
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:16px;"> <div style="display:flex; gap:8px; justify-content:flex-end; margin-top:16px;">
<button onclick="closeEditCat()" class="btn" style="background:var(--bg); border:1px solid var(--border); color:var(--text);">{{$d.T.Get "settings.categories.modal_edit.btn_cancel"}}</button> <button onclick="closeEditCat()" class="btn" style="background:var(--bg); border:1px solid var(--border); color:var(--text);">{{$d.T.Get "settings.categories.modal_edit.btn_cancel"}}</button>
@ -154,11 +181,13 @@ function deleteCat(id) {
.then(r => { if (r.ok) location.reload(); }); .then(r => { if (r.ok) location.reload(); });
} }
function editCat(id, name, budgetCents, color) { function editCat(id, name, budgetCents, color, goalID) {
editingCatID = id; editingCatID = id;
document.getElementById('edit-cat-name').value = name; document.getElementById('edit-cat-name').value = name;
document.getElementById('edit-cat-budget').value = budgetCents || ''; document.getElementById('edit-cat-budget').value = budgetCents || '';
document.getElementById('edit-cat-color').value = color || '#6366f1'; document.getElementById('edit-cat-color').value = color || '#6366f1';
const goalSel = document.getElementById('edit-cat-goal');
if (goalSel) goalSel.value = goalID || '';
document.getElementById('edit-cat-modal').style.display = 'flex'; document.getElementById('edit-cat-modal').style.display = 'flex';
} }
@ -169,13 +198,17 @@ function closeEditCat() {
function saveEditCat() { function saveEditCat() {
if (!editingCatID) return; if (!editingCatID) return;
const body = new URLSearchParams({ const goalSel = document.getElementById('edit-cat-goal');
name: document.getElementById('edit-cat-name').value, fetch('/categories/' + editingCatID, {
budget_cents: document.getElementById('edit-cat-budget').value || '0', method: 'PUT',
color: document.getElementById('edit-cat-color').value, headers: {'Content-Type': 'application/json'},
}); body: JSON.stringify({
fetch('/categories/' + editingCatID, { method: 'PUT', body }) name: document.getElementById('edit-cat-name').value,
.then(r => { if (r.ok) { closeEditCat(); location.reload(); } }); budget_cents: parseInt(document.getElementById('edit-cat-budget').value || '0'),
color: document.getElementById('edit-cat-color').value,
goal_id: goalSel ? goalSel.value : '',
}),
}).then(r => { if (r.ok) { closeEditCat(); location.reload(); } });
} }
document.getElementById('add-account-form')?.addEventListener('submit', function(e) { document.getElementById('add-account-form')?.addEventListener('submit', function(e) {