Commit Graph
2 Commits
Author SHA1 Message Date
buzzromainandClaude Opus 5 74bb980271 feat(goals): a reserve measured in months of spending, not a fixed sum (#3180)
* feat(goals): a reserve measured in months of spending, not a fixed sum

"Six months of expenses" is the way people actually describe an emergency
fund, and it is a moving number: what covered six months last January
does not cover six months today. A reserve pinned to a figure typed once
drifts quietly out of date, and the drift always runs the wrong way — the
bar reads full while the cover shrinks.

`target_amount` stays the single source of truth, rewritten monthly by
RefreshMaintainedGoalTargetsJob. That is the whole architectural decision
here. An `effective_target_amount` would have been the obvious shape and
the wrong one: `remaining_amount`, `progress_percent`, `Goal.summary_for`,
the ring, the card and every future caller would each have had to learn
which target to read. None of them change.

The job refuses to write more often than it writes, on purpose:

- a family with no spending history yet computes a floor of zero, which
  would both violate the `target_amount > 0` constraint and read to the
  user as "your reserve is complete". The previous target stands.
- a figure identical to the current one is not rewritten, so a reserve
  does not collect a fresh updated_at every month for nothing.
- a write that fails validation leaves the target alone and is recorded
  through DebugLogEntry, not just the application log: a reserve frozen
  at a stale floor is invisible to the user, who has no reason to suspect
  the number stopped moving.

The job reads the family's spending, not a member's view. IncomeStatement
falls back to Current.user when nobody says otherwise, which in a
background job is nobody — so the scope is the whole family, and the
number is the same whoever is looking. That is deliberate, and matches
how the rollover chain had to be pinned.

`target_months` is refused outside a months-mode reserve rather than
tolerated: a number nothing reads would sit there looking meaningful,
and the job would skip it for reasons no one could see.

schema.rb is hand-edited again — verified against a real migration on a
throwaway database, structures identical. The dumper on this Rails
version also rewrites every check-constraint cast, so the new constraint
is written in the file's existing style rather than the dumper's.

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

* fix(goals): pin the reserve calculation to the family, and get it right on day one

Review of the previous commit raised two things, and they are the same
thing seen from either end.

`IncomeStatement.new(family)` looked family-wide but was only so by
accident. Its constructor falls back to `Current.user`, and eligible_accounts
narrows to that user's accounts when one is present. The single caller was
a background job, where nobody is current — so the figure was correct for
the reason that it happened to be computed nowhere else. `target_amount`
belongs to the whole family: derived from a viewer's slice of the accounts,
it would have started moving with whoever last triggered it. This is the
same fallback that made the budget rollover carry depend on its reader.
The account scope is now passed explicitly, so the calculation is safe
whatever calls it.

That mattered immediately, because the second point required a new caller.
A reserve created as "6 months of expenses" had no floor computed until
the 1st of the following month: the user chose the mode, guessed an
amount, and lived with a wrong target for up to a month. The feature's
first impression was its least convincing moment. The floor is now
computed on creation, and whenever the mode or the number of months
changes — but never on an unrelated save, so the monthly job keeps owning
the cadence and renaming a goal cannot silently move a financial figure.

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

* fix(goals): keep a months-based floor derived, and in the right currency

Review on #3180.

The median comes back in FAMILY currency and `target_amount` is stored in
the GOAL's, so a EUR reserve in a USD family read a 3,000 dollar floor as
3,000 euros — and rewrote it that way every month, silently. Converted
now, and when there is no rate for the day the previous target stands:
the same safe failure the method already took for a family with no
spending history, because a stale floor beats a wrong one.

The callback also skipped a target_amount edit, so the form could persist
an arbitrary figure under a "six months of expenses" label until the next
monthly refresh. It runs on that edit now and overwrites it — and when
there is nothing to derive from, restores what the reserve already had
rather than accepting the typed figure.

The form marks the field read-only in that mode. The model does not depend
on it, but a field that silently discards what you type is worse than one
you cannot type into.

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 22:02:57 +02:00
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