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:
parent
4cfe80e3d5
commit
e93cb38756
@ -849,6 +849,19 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
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{
|
||||
ID: bson.NewObjectID().Hex(),
|
||||
UserID: a.UserID,
|
||||
@ -857,7 +870,7 @@ func (h *Handler) CreateTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
Description: body.Description,
|
||||
AmountCents: body.AmountCents,
|
||||
Category: body.Category,
|
||||
GoalID: body.GoalID,
|
||||
GoalID: goalID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@ -1300,6 +1313,7 @@ func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
BudgetCents int64 `json:"budget_cents"`
|
||||
GoalID string `json:"goal_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
@ -1311,6 +1325,7 @@ func (h *Handler) Categories(w http.ResponseWriter, r *http.Request) {
|
||||
Name: body.Name,
|
||||
Color: body.Color,
|
||||
BudgetCents: body.BudgetCents,
|
||||
GoalID: body.GoalID,
|
||||
}
|
||||
if err := h.store.updateCategory(r.Context(), cat); err != nil {
|
||||
slog.Error("update category", "err", err)
|
||||
@ -2710,6 +2725,12 @@ func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
accounts, _ := h.store.getAccounts(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{
|
||||
T: h.t(r),
|
||||
@ -2720,6 +2741,8 @@ func (h *Handler) Settings(w http.ResponseWriter, r *http.Request) {
|
||||
Tab: tab,
|
||||
Accounts: accounts,
|
||||
Categories: categories,
|
||||
Goals: goals,
|
||||
GoalNameByID: goalNameByID,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -258,6 +258,9 @@ goals = "Goal contributions"
|
||||
goals_link = "manage →"
|
||||
free_cash = "Free cash"
|
||||
month_progress = "Month progress"
|
||||
what_now = "What now?"
|
||||
fund_link = "Fund"
|
||||
all_funded = "All committed goals funded this month ✓"
|
||||
|
||||
# ── Transactions ─────────────────────────────────────────────────────────────
|
||||
|
||||
@ -830,6 +833,7 @@ placeholder_budget = "Monthly budget (cents)"
|
||||
btn_add = "Add"
|
||||
col_category = "Category"
|
||||
col_budget = "Monthly Budget"
|
||||
col_goal = "Auto-tags goal"
|
||||
btn_delete = "Delete"
|
||||
empty_msg = "No categories yet — add one above."
|
||||
confirm_delete = "Delete this category?"
|
||||
@ -838,6 +842,9 @@ confirm_delete = "Delete this category?"
|
||||
title = "Edit Category"
|
||||
placeholder_name = "Name"
|
||||
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_save = "Save"
|
||||
|
||||
|
||||
@ -258,6 +258,9 @@ goals = "Contribuições para objetivos"
|
||||
goals_link = "gerir →"
|
||||
free_cash = "Dinheiro livre"
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -830,6 +833,7 @@ placeholder_budget = "Orçamento mensal (cêntimos)"
|
||||
btn_add = "Adicionar"
|
||||
col_category = "Categoria"
|
||||
col_budget = "Orçamento Mensal"
|
||||
col_goal = "Etiqueta automática"
|
||||
btn_delete = "Eliminar"
|
||||
empty_msg = "Sem categorias ainda — adicione uma acima."
|
||||
confirm_delete = "Eliminar esta categoria?"
|
||||
@ -838,6 +842,9 @@ confirm_delete = "Eliminar esta categoria?"
|
||||
title = "Editar Categoria"
|
||||
placeholder_name = "Nome"
|
||||
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_save = "Guardar"
|
||||
|
||||
|
||||
@ -39,6 +39,8 @@ type Category struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
Color string `bson:"color" json:"color"`
|
||||
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 {
|
||||
@ -322,6 +324,8 @@ type SettingsData struct {
|
||||
Tab string // "accounts" | "categories"
|
||||
Accounts []Account
|
||||
Categories []Category
|
||||
Goals []Goal // for category → goal linking dropdown
|
||||
GoalNameByID map[string]string // goalID → name, for table display
|
||||
}
|
||||
|
||||
type HouseholdData struct {
|
||||
|
||||
@ -103,11 +103,13 @@ func (s *Store) createCategory(ctx context.Context, c *Category) error {
|
||||
func (s *Store) updateCategory(ctx context.Context, c *Category) error {
|
||||
ctx, span := mongo.StartSpan(ctx, "Store.updateCategory")
|
||||
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,
|
||||
"color": c.Color,
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
@ -153,6 +153,29 @@
|
||||
background:var(--text3); transition:width 1s ease;"></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>
|
||||
|
||||
<style>
|
||||
@ -215,6 +238,11 @@ function wfToggleCat(id) {
|
||||
list.style.display = open ? 'none' : 'block';
|
||||
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>
|
||||
|
||||
<!-- 3 diagnostic cards -->
|
||||
|
||||
@ -87,7 +87,12 @@ tr:last-child td { border-bottom:none; }
|
||||
<div class="s-card">
|
||||
{{if $d.Categories}}
|
||||
<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>
|
||||
{{range $d.Categories}}
|
||||
<tr id="cat-row-{{.ID}}">
|
||||
@ -101,9 +106,19 @@ tr:last-child td { border-bottom:none; }
|
||||
{{else}}
|
||||
<span id="cat-budget-{{.ID}}" style="color:var(--muted);">—</span>
|
||||
{{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>
|
||||
</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;">
|
||||
<button class="btn btn-danger" onclick="deleteCat('{{.ID}}')">{{$d.T.Get "settings.categories.btn_delete"}}</button>
|
||||
</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-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;">
|
||||
{{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 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>
|
||||
@ -154,11 +181,13 @@ function deleteCat(id) {
|
||||
.then(r => { if (r.ok) location.reload(); });
|
||||
}
|
||||
|
||||
function editCat(id, name, budgetCents, color) {
|
||||
function editCat(id, name, budgetCents, color, goalID) {
|
||||
editingCatID = id;
|
||||
document.getElementById('edit-cat-name').value = name;
|
||||
document.getElementById('edit-cat-budget').value = budgetCents || '';
|
||||
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';
|
||||
}
|
||||
|
||||
@ -169,13 +198,17 @@ function closeEditCat() {
|
||||
|
||||
function saveEditCat() {
|
||||
if (!editingCatID) return;
|
||||
const body = new URLSearchParams({
|
||||
const goalSel = document.getElementById('edit-cat-goal');
|
||||
fetch('/categories/' + editingCatID, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: document.getElementById('edit-cat-name').value,
|
||||
budget_cents: document.getElementById('edit-cat-budget').value || '0',
|
||||
budget_cents: parseInt(document.getElementById('edit-cat-budget').value || '0'),
|
||||
color: document.getElementById('edit-cat-color').value,
|
||||
});
|
||||
fetch('/categories/' + editingCatID, { method: 'PUT', body })
|
||||
.then(r => { if (r.ok) { closeEditCat(); location.reload(); } });
|
||||
goal_id: goalSel ? goalSel.value : '',
|
||||
}),
|
||||
}).then(r => { if (r.ok) { closeEditCat(); location.reload(); } });
|
||||
}
|
||||
|
||||
document.getElementById('add-account-form')?.addEventListener('submit', function(e) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user