Commit Graph
3 Commits
Author SHA1 Message Date
buzzromainandClaude Opus 5 6d7ca3584c feat(goals): reserves you maintain, not goals you finish (#3167)
* feat(goals): reserves you maintain, not goals you finish

An emergency fund is not a goal you reach and close — it is a level you
hold, and every withdrawal is a shortfall to make good. Sure treated it
like anything else: at 100% it offered to close it, which would release
the very money being set aside; a withdrawal dropped the bar with no
sign that anything was owed.

`kind` (added by the lifecycle lot without behavior) now means something.
A maintained goal is `funded` or `depleted`, never `reached` — sitting at
its floor is a steady state, not an achievement to file away. `complete`
is refused by an AASM guard rather than merely hidden, so no path can
release a reserve's earmark.

Two ordering traps, both of which would have made a drained reserve
invisible:

`ACTIVE_DISPLAY_STATUS_RANK` falls back to 4 for any status it does not
know, so an unranked `:depleted` would sort a drained emergency fund
below everything else — the exact opposite of what it means. It ranks
alongside `:behind` now, and `:funded` sorts near the end with the goals
that need nothing.

`behind_pace?` excludes reserves. `monthly_target_amount` and `pace` both
derive from `target_date`, which a reserve does not have, so "save X/month
to catch up" would be advice about a deadline that does not exist.

The form leads with the choice, since it changes what the rest of it
means, and hides the target date for a reserve rather than disabling it —
a hidden field cannot submit a stale value that would then drive a pace.
The card states the shortfall, which is exactly `remaining_amount`. The
panel that offers a one-off its closing action tells a reserve it is
intact and offers nothing, because there is nothing to do.

Scope: fixed targets only. Targets expressed in months of expenses, the
monthly refresh job, and the depletion insight are the next two PRs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4

* fix(goals): let a reserve behave like one everywhere it is shown

Addresses review feedback on #3167.

The kind selector never hid the target date. `data-controller="goal-kind"`
sat on the selector div while its `dateField` target is a sibling, so
`dateFieldTargets` came back empty and picking "Reserve to maintain" left the
deadline on screen and submittable. The controller moves to the form wrapper,
which encloses both.

Hiding a field is not enforcement, so the model now clears `target_date` for a
maintained goal. Normalising rather than rejecting: the field is hidden, and an
error about something the user cannot see is not actionable. A date could only
arrive through a conversion or a crafted request, and either way a stored
deadline would drive a pace the reserve does not have.

A completed goal could be switched to `maintained` from the edit form. It then
sat in a released state — one that has handed its earmark back — while the show
page promised its money stays reserved, and `complete` for reserves is refused
precisely to prevent that state. `kind` is now locked while released: reopen
first.

Reserves counted against the "goals on track" tile. Their statuses are
`funded`/`depleted`, which match none of the exclusions in `tracked_total`, so
they could never reach the numerator and a family with one reserve read
"0 of 1 on track" for a goal working exactly as intended.

Two more places still spoke of pace to something that has none. `pace_line` is
suppressed for reserves on the card, and a depleted reserve gets its own panel
before the projection card — the projection's summary, catch-up line and colour
are all built from a deadline. What a drained reserve needs is the number the
projection cannot show: how much is missing from the floor.

The French celebration copy read "Votre réserve est à son niveau", which never
says which level.

Each guard was confirmed load-bearing by removing it and watching its test
fail. bin/rails test: 6962 runs, 28003 assertions, 0 failures. RuboCop,
erb_lint and Brakeman clean.

Left open deliberately: extracting the show page's lifecycle panel into a
ViewComponent. The guideline behind it is right, but the refactor is wider than
this round of fixes and belongs on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): finish teaching the status consumers about reserves

Second round of review feedback on #3167: two consumers still had no branch
for the reserve statuses.

`ProgressRingComponent#percent_text_class` styled only `:reached` as success,
so a funded reserve — a floor the user is holding exactly as intended — fell
back to the neutral colour and read as unfinished.

`status_callout_context` had no `:depleted` branch, so a drained reserve showed
no callout at all: the one status that most deserves a line of explanation was
the only one saying nothing. It now names the shortfall.

`:funded` deliberately keeps no callout — a reserve at its level has nothing to
report, and the celebration panel already says so. A test pins that, so the
silence reads as a decision rather than another missing branch.

bin/rails test: 6964 runs, 28008 assertions, 0 failures. RuboCop and Brakeman
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): lock the kind on the state the goal is actually in

Addresses review feedback on #3167, on the guard that landed in c1c7f4f7.

`kind_locked_while_released` read the in-memory `state`, so a single write
setting `state: "active"` alongside the new kind saw the goal as already
reopened and waved it through.

The end state looks legitimate — active and maintained — which is why the hole
is easy to miss. It is not: the direct write skipped the `reopen` transition,
and with it `thaw_completed_amount!`. `completed_amount` survived, so
`current_balance` returned that frozen snapshot forever on a live reserve.
Reopening has to be its own gesture, because it is the gesture that thaws.

Now reads `state_in_database`, with a regression test on the combined write
asserting both that it is refused and that the frozen amount is untouched.

Confirmed load-bearing by reading the attribute again and watching it fail.
bin/rails test: 6969 runs, 28019 assertions, 0 failures. RuboCop and Brakeman
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): send an empty reserve to the shortfall panel, not the empty state

Addresses the last review thread on #3167.

The `maintained?` branch sat after the zero-balance/zero-pace one, so a
brand-new reserve matched the generic "make your first transfer" card. I had
put it there on purpose, thinking a reserve with nothing in it wanted the
first-transfer nudge. The review is right that it does not: it is still a
reserve short of its floor, and the shortfall panel says so with the saved,
target and missing amounts, where the generic card says none of them.

Ordering it after also meant evaluating `pace` on a goal that has no pace to
evaluate.

bin/rails test: 6965 runs, 0 failures. RuboCop and erb_lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): stop a paused goal outranking a reserve that is whole

Review on #3175.

`:funded` and paused both ranked 3 in `active_display_sort`, so the tie
broke on name and a paused goal called "Alpha" sat above a reserve called
"Zeta" that was fully funded — the list saying the paused one wanted
attention more. Paused now ranks behind every status, which is what the
comment above the table already claimed.

The seven panels on the goal page were hand-rolled repetitions of
`DS::Card`'s exact shell, two of them adjacent and identical. They render
through the primitive now, so their surface styling cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* refactor(goals): move the lifecycle panel decision out of the template

Review on #3167 and #3180.

Which panel a goal gets is a lifecycle question with five answers, and the
template worked it out inline from `completed?`, `maintained?`, `one_off?`,
`status` and `may_complete?` — five predicates deep in ERB where the
ordering between them was load-bearing and nothing said so.

`Goals::LifecyclePanelComponent` answers it in Ruby and the template
renders the answer. The markup moves across unchanged, keys made absolute
because a relative `t(".x")` in a component resolves against the
component's own path rather than the page these strings belong to.

The order is now stated once, where it can be read and tested:
`:reserve_shortfall` before `:empty`, because a brand-new reserve sits at
zero balance and zero pace and the generic "make your first transfer" card
would otherwise swallow it.

Closing from the panel now confirms, as the header menu already did.
Completing releases the goal's earmarked money, and the panel offered that
in one click. Both go through `goal_complete_confirm` rather than building
the wording twice — two copies drifting apart is how one ends up
describing the wrong consequence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): refresh the pace suggestion when the deadline is cleared

Assigning `input.value = ""` fires no event, so `goal-form#suggestedChanged`
never ran: selecting "Reserve to maintain" cleared the date but left the
monthly pace suggestion on screen, derived from a deadline the goal no
longer has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): let a depleted reserve look as urgent as it is

Review on #3179 and #3180.

`Goal#needs_attention?` names the pair of statuses that mean "this one wants
looking at" — a goal off its pace and a reserve below its floor. Three
places were spelling that out and the Plan hub's progress bar had fallen
behind, so a depleted reserve got a neutral bar an inch from its own amber
status pill: the same goal reported as needing attention and not.

`projection_summary` told a funded reserve it had "hit the target, no
projection needed". A reserve holds a level; there is no finish line to
project toward and no target to have hit. It does not reach that panel
today — the shortfall and celebration panels catch it first — but the
method reads as the single source of truth for that subtitle and should not
hand a caller a one-off's wording.

The legend swatches are bordered spans now rather than inline SVG, and the
label takes `text-xs` instead of an arbitrary 11px. The projection swatch
keeps the chart's own colour variables in an inline style rather than
`border-success` / `border-warning`: the chart hard-codes green-600 and
yellow-600, and a legend whose colour does not match the line it describes
is worse than the markup it would save.

The Plan-card test counts warning bars rather than matching one. A fixture
goal is already off its pace, so the markup is on the page either way and a
presence check passed without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 08:20:43 +02:00
e5750a6c09 feat(budgets): add per-user personal budgets with strict isolation (#2891)
* feat(budgets): add per-user personal budgets with strict isolation

Families can now opt into personal budgets (toggleable via family
settings): each family member gets their own budget for a given
period instead of sharing a single family-wide budget.

- Add families.personal_budgets flag and budgets.user_id, with
  partial unique indexes so shared budgets (user_id IS NULL) and
  personal budgets (user_id IS NOT NULL) can't collide.
- Budget.find_or_bootstrap scopes lookup/creation by user when the
  family has personal_budgets enabled.
- Scope most_recent_initialized_budget (used to seed a new budget
  from the prior period) by user_id so one user's copy-forward never
  bleeds into another user's budget.
- budgets.user_id cascades on user deletion so personal budgets don't
  outlive their owner.

* feat(budgets): enforce user-specific budget ownership and cascade deletion

* feat(budgets): display user name for personal budgets in budget card on the plan section

* feat(budgets): enhance personal budgets display for admins with preview feature indication

* feat(budgets): enforce user-specific budget and category visibility for personal budgets

* feat(budgets): create budget section titles and add translations notice in preferences

* feat(budgets): let household and personal budgets coexist with sharing

Previously enabling personal_budgets made the shared household budget
unreachable. Budget.find_or_bootstrap now takes an explicit household:
flag so both can be resolved independently for the same period, with a
new household_budget_enabled family setting to opt out of the household
side and keep personal budgets only.

Adds a BudgetShare model (read_only/read_write) so a member can grant
another family member access to their personal budget, enforced via
Budget#viewable_by?/editable_by? across BudgetsController,
BudgetCategoriesController, PlansController, and the read-only API.
Preferences gains a Budget sharing card (gated on preview access like
the rest of the personal budgets UI) and an owner switcher pill (
Household / mine / shared-with-me) appears on the budget page and the
Plan hub card.

Also fixes personal budgets showing the same "actual spending" as the
household budget: actual spending/income now scope to the budget
owner's own accounts instead of the viewer's full accessible set,
via a new accounts: override on IncomeStatement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(budgets): enhance budget switcher with icons and improved styling

* feat(budgets): remove user name display from budget card and header

* feat(budgets): remove unique index on taggable_type and taggable_id in taggings

* feat(budgets): enhance budget sharing functionality and improve UI elements

* Collapse personal budget migrations

---------

Signed-off-by: JulienGourmet <69808509+jubbakka@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-08-18 08:36:46 +02:00
Guillem Arias Fauste 9d3879a859 feat(plan): unify Budgets and Goals under a single Plan tab (#2687)
* feat(plan): unify Budgets and Goals under a single Plan tab

Preview users get one "Plan" nav entry (compass icon) in place of the
separate Budgets and preview-gated Goals items. It fronts a new /plan
hub with two summary cards — this month's budget (spent vs budgeted,
days left, top categories) and active goals (total saved vs targets,
behind/pending counts, per-goal rows) — each drilling into the existing
/budgets and /goals pages, whose breadcrumbs now start Home > Plan.

The two features share a home, not a model: no schema changes, no URL
changes. Users without preview features keep exactly the pre-Plan nav
(Budgets entry, Goals hidden), and /plan falls through to /budgets for
them.

Supporting changes:
- Goal.active_prepared_for: index-style sorted active goals with the
  family-wide pooled-allocations + market-flows injection reused
- Goal::FUNDABLE_ACCOUNT_TYPES and Goal::ACTIVE_DISPLAY_STATUS_RANK
  extracted from GoalsController
- Budget#days_remaining (same day math as suggested_daily_spending)
- Breadcrumbable#plan_breadcrumb_prefix for the conditional Plan crumb
- New BudgetsController web tests (previously untested) + Plans tests

* fix(plan): address review feedback on the Plan hub

- Keep the "All goals" footer link rendered when the family has only
  completed/archived goals — the hub is a preview user's only route to
  the goals index now that the Goals nav entry is gone (Codex P2)
- Replace the two hand-rolled footer button-links with DS::Link
  (variant secondary, full_width, right icon) per DS Drift Patrol
- Fix DS::Link template comparing icon_position against the string
  "right" — the initializer symbolizes it, so right-positioned icons
  never rendered on links (DS::Button already compared symbols);
  existing callers passing icon_position now get the layout they asked for
- Clamp progress-bar percentages to 0..100 instead of capping only the
  upper bound (CodeRabbit)

* refactor(plan): single source of truth for goal loading, sorting, and counts

Addresses jjmata's draft review notes:

- GoalsController#index now builds on the shared Goal loaders instead
  of hand-rolling its own copy: Goal.prepared_for (preloads + family-
  wide backing-math injection, scope-able) and Goal.active_display_sort
  carry the algorithm once; active_prepared_for composes them for the
  hub. The controller-side constant alias is gone
- One definition of "behind pace": Goal#behind_pace? (excludes paused —
  pausing stops the pace clock on purpose). Both the Plan hub summary
  and GoalsController#kpi_payload's behind/needs-this-month figures use
  it, so adjacent pages can't disagree. While there, the kpi on-track
  numerator also excludes paused goals — it was counted against a
  paused-excluding denominator, so the "X of Y" fraction could exceed
  its own total
- BudgetCategory#suggested_daily_spending calls Budget#days_remaining
  instead of keeping an inline copy of the day math
- Per the fat-model convention, the hub's aggregation moved off the
  controller: Budget#top_spending_categories(limit:) and
  Goal.summary_for(goals, currency:)

* fix(plan): move Edit budget/New goal into their own cards

Both actions lived in the hub's shared page header, unlinked to either
card and, on mobile, wrapping above all content before any real data
appeared. Each now lives in its own card's header instead: Edit budget
as a compact icon-only control next to the status pill (only when a
budget exists — the uninitialized state already has its own "Set up"
CTA), New goal as a small outline button next to the goals count (only
once there's a goal to sit beside; the empty state keeps its own CTA).

Also swaps the edit icon from "pencil" to "square-pen" — at the sizes
these header controls render, lucide's plain pencil is a thin diagonal
stroke that reads noticeably smaller than a neighboring bold glyph like
"plus", even in the same size box. square-pen carries more visual mass
and reads clearly at the same footprint.

* fix(plan): match established DS precedent for the card header actions

Edit budget was a bare icon-only button; verified against the app's
own precedent for this exact action (app/views/budgets/_budget_donut.html.erb,
the budget card already shipped on /budgets) and it's a labeled
secondary link with a trailing pencil, not icon-only and not a
three-dot menu. Matched that: DS::Link, variant secondary, size sm,
icon right. New goal gets the same treatment for consistency between
the two cards' header actions, rather than the full-page-scoped
"primary" weight goals/index.html.erb uses for its own create button —
that's calibrated for a whole page's sole CTA, not a compact card.

Adding a labeled button (wider than the bare icon this replaces)
crowded the header row on mobile enough to wrap "This month" onto two
lines and truncate the "· July 2026" meta away entirely. Header rows
now wrap as a whole (flex-wrap) with the title pinned (shrink-0) so
the action cluster drops to its own line instead of squeezing the
title and meta text.

Also drops the hub's footer note ("Budgets cap your spend; goals track
what you're saving toward...") — redundant with the subtitle right
above the cards.

* fix(plan): lead the budget card header with status, not the edit action

On Track/Over/Warning is what a glance at the card wants first; Edit
budget is the secondary action. Swapped their order so status leads
and the edit control trails, gap-2 unchanged.

* fix(plan): put the status pill on the left, next to the title

Meant the left side of the card, not just left of the edit button. On
Track/Over/Warning now sits beside "This month · July 2026" in normal
flow; ml-auto carries only the Edit budget link, alone on the right —
matching the goals card's own left-meta/right-action split ("· 7
active" left, "New goal" right).

* fix(plan): lead Edit budget with its icon, matching same-shape precedent

Wrong axis on the earlier match: _budget_donut's trailing pencil labels
the VALUE itself ("$12,850 ✎"), not a static action. Our button's label
is a static "Edit budget", and that shape takes a leading icon
everywhere else it appears — the categories "Edit" on budgets/show.html.erb
(icon: settings-2) and "Edit split" in transactions/show.html.erb both
lead with their icon. Drops icon_position: :right so it defaults to
left, matching New goal's shape in the sibling card.

* fix(plan): use the divider token for row separators, not border-primary

Traced against the dashboard outflows list (pages/dashboard/_outflows_donut.html.erb),
which renders its row separators via shared/_ruler → border-divider
(border-tertiary: black/8%, white/10%). Our category and goal rows used
border-b border-primary instead (black/15%, white/30%) — 2-3x heavier
than the established row-separator weight elsewhere in the app. Swapped
both to border-divider.

* fix(plan): lift the duplicated card shell into DS::Card

Codex P1: _budget_card.html.erb and _goals_card.html.erb hand-rolled
the identical "bg-container rounded-xl shadow-border-xs p-5 flex
flex-col" shell twice, with no DS:: card primitive to reach for
instead. Extracted a minimal wrapper — content-only, no header/footer
slots — matching what both cards actually need right now; the roadmap
cards (envelopes #2153, retirement #2044) can adopt it too instead of
copying the class string a third time.

Verified pixel-identical in a browser: same classes, same DOM shape,
just rendered through the component.

* fix(plan): batch pace queries before sorting goals

Codex P2: active_display_sort calls goal.status per goal to build the
sort key; Goal#status reaches Goal#pace for any goal with a
target_date, which fired its own Entry.sum(:amount) query per goal.
The /plan hub renders only the first 5 of active_prepared_for's list,
but paid the full O(N) query cost sorting all of them.

Adds Goal.pace_for(family) (account_id => 90-day net inflow), grouped
in one query and injected via inject_backing_math! alongside the
existing pooled_allocations/market_flows pattern. #pace now sums from
that shared map instead of firing its own query — same math, same
90-day window, same exclusions, just computed once per family instead
of once per goal.
2026-08-01 08:51:08 +02:00