Files
sure/app/views/goals/_form.html.erb
T
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

178 lines
11 KiB
ERB

<%# locals: (goal:, linkable_accounts:, currently_linked_account_ids: [], pooled_allocations: {}) %>
<%# goal-kind lives on this wrapper, not on the kind selector: its
dateField target is a sibling of the radios, and a controller only sees
targets inside its own element. Scoped to the selector, dateFieldTargets
came back empty and picking "Reserve to maintain" never hid the date. %>
<div data-controller="goal-form goal-kind"
data-goal-form-currency-value="<%= Current.family.primary_currency_code %>"
data-goal-form-require-account-value="<%= !goal.persisted? %>"
data-goal-form-suggested-with-date-value="<%= t("goals.form.suggested_with_date") %>"
data-goal-form-suggested-no-date-value="<%= t("goals.form.suggested_no_date") %>">
<%= styled_form_with model: goal,
url: goal.persisted? ? goal_path(goal) : goals_path,
method: goal.persisted? ? :patch : :post,
class: "space-y-5",
data: { action: "submit->goal-form#validateOnSubmit" } do |f| %>
<div class="flex justify-center">
<%= render "color_picker", form: f, colors: Goal::COLORS, icons: Goal::ICONS %>
</div>
<%# Kind first: it changes what the rest of the form means. A reserve has
no deadline — target_date is hidden rather than disabled so it cannot
be submitted with a value that would then drive a pace the goal does
not have. %>
<div class="grid grid-cols-2 gap-2">
<% Goal::KINDS.each do |kind| %>
<label class="flex items-start gap-2 rounded-lg border border-primary p-3 cursor-pointer has-[:checked]:border-secondary has-[:checked]:bg-surface-inset">
<%= f.radio_button :kind, kind,
class: "radio mt-0.5 shrink-0",
data: { goal_kind_target: "radio", action: "change->goal-kind#refresh" } %>
<span class="min-w-0">
<span class="block text-sm font-medium text-primary"><%= t("goals.form.kinds.#{kind}.label") %></span>
<span class="block text-xs text-secondary"><%= t("goals.form.kinds.#{kind}.hint") %></span>
</span>
</label>
<% end %>
</div>
<div>
<%= f.text_field :name,
placeholder: t("goals.form.fields.name_placeholder"),
autofocus: true,
required: true,
label: t("goals.form.fields.name"),
data: { goal_form_target: "nameInput", action: "input->goal-form#nameChanged" } %>
<p class="<%= "hidden" unless goal.errors[:name].any? %> mt-1.5 text-xs text-destructive" data-goal-form-target="nameError"><%= t("goals.form.errors.name_required") %></p>
</div>
<%# Only meaningful for a reserve, and only in months mode — the Stimulus
controller shows it accordingly. The amount field stays visible either
way: in months mode it shows what the job last computed, which is the
figure the user is actually saving against — read-only there, because
in that mode it is derived rather than chosen. %>
<div data-goal-kind-target="modeField" class="hidden">
<%= f.select :target_mode,
Goal::TARGET_MODES.map { |mode| [ t("goals.form.target_modes.#{mode}"), mode ] },
{ label: t("goals.form.fields.target_mode") },
data: { goal_kind_target: "modeSelect", action: "change->goal-kind#refresh" } %>
</div>
<div data-goal-kind-target="monthsField" class="hidden">
<%= f.number_field :target_months,
label: t("goals.form.fields.target_months"),
min: 1,
step: 1,
placeholder: "6" %>
<p class="mt-1 text-xs text-secondary"><%= t("goals.form.fields.target_months_hint") %></p>
</div>
<div class="grid grid-cols-2 gap-3 items-start">
<div data-goal-kind-target="amountField">
<%= f.money_field :target_amount,
label: t("goals.form.fields.target_amount"),
hide_currency: true,
required: true,
amount_data: { goal_form_target: "amountInput", action: "input->goal-form#suggestedChanged" } %>
<p class="<%= "hidden" unless goal.errors[:target_amount].any? %> mt-1.5 text-xs text-destructive" data-goal-form-target="amountError"><%= t("goals.form.errors.amount_required") %></p>
</div>
<div data-goal-kind-target="dateField">
<%= f.date_field :target_date,
label: t("goals.form.fields.target_date"),
data: { goal_form_target: "dateInput", action: "input->goal-form#suggestedChanged" } %>
</div>
</div>
<p class="text-xs text-secondary italic tabular-nums privacy-sensitive hidden" data-goal-form-target="suggested"></p>
<div>
<div class="mb-2">
<span class="block text-sm font-medium text-primary"><%= t("goals.form.fields.funding_accounts") %></span>
<p class="text-xs text-secondary mt-0.5"><%= t("goals.form.fields.funding_accounts_hint") %></p>
<p class="text-xs text-subdued mt-0.5"><%= t("goals.form.fields.earmark_hint") %></p>
</div>
<%# Its own controller, not goal-form: that one is already at 10 targets
against the 7 the project guidelines suggest, and this needs none of
its state. Three targets here. %>
<div class="bg-container-inset rounded-lg p-1"
data-controller="goal-earmark"
data-goal-earmark-currency-value="<%= goal.currency.presence || Current.family.primary_currency_code %>"
<%# The APPLICATION locale, not the browser's. `Intl.NumberFormat`
with `undefined` lets the browser pick, so a French user on an
English-locale browser read separators and symbol placement that
matched nothing else on the page. The amounts themselves cannot
be formatted server-side — they change with every keystroke — so
the server decides the locale and the client applies it. %>
data-goal-earmark-locale-value="<%= I18n.locale %>"
data-goal-earmark-whole-balance-value="<%= t("goals.form.earmark.whole_balance") %>"
data-goal-earmark-prorata-value="<%= t("goals.form.earmark.prorata") %>"
data-goal-earmark-headroom-value="<%= t("goals.form.earmark.headroom") %>">
<% linked_allocation_by_account = goal.goal_accounts.index_by(&:account_id) %>
<% grouped = linkable_accounts.group_by { |a| a.subtype.to_s.presence || "other" } %>
<% grouped.each_with_index do |(subtype, accts), group_idx| %>
<div class="px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t("goals.form.subtypes.#{subtype}", default: subtype.titleize) %></div>
<div class="bg-container rounded-md <%= "mb-1" if group_idx < grouped.size - 1 %>">
<% accts.each_with_index do |account, idx| %>
<% linked_ga = linked_allocation_by_account[account.id] %>
<div class="px-3 py-2.5 hover:bg-surface-hover <%= "border-t border-subdued" if idx > 0 %>"
data-balance="<%= account.balance.to_d %>"
data-earmarked-by-others="<%= earmarked_by_other_goals(account, pooled: pooled_allocations, current_goal: goal) %>">
<div class="flex items-center gap-3">
<label class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
<%= check_box_tag "goal[account_ids][]",
account.id,
currently_linked_account_ids.include?(account.id.to_s),
id: "goal_account_ids_#{account.id}",
class: "checkbox checkbox--light shrink-0",
data: {
goal_form_target: "linkedAccountCheckbox",
goal_earmark_target: "checkbox",
action: "change->goal-form#linkedAccountChanged change->goal-earmark#refresh"
} %>
<%= render Goals::AvatarComponent.new(name: account.name, color: Goals::AvatarComponent.color_for(account.name), size: "md") %>
<div class="min-w-0">
<p class="text-sm font-medium text-primary truncate"><%= account.name %></p>
<% row_subtype = account.subtype.to_s.presence || subtype %>
<p class="text-xs text-secondary tabular-nums privacy-sensitive"><%= t("goals.form.subtypes.#{row_subtype}", default: row_subtype.titleize) %> · <%= Money.new(account.balance, account.currency).format %></p>
</div>
</label>
<%= text_field_tag "goal[allocations][#{account.id}]",
linked_ga&.allocated_amount&.to_s("F"),
placeholder: t("goals.form.fields.whole_balance"),
inputmode: "decimal",
autocomplete: "off",
aria: { label: t("goals.form.fields.earmark_for", account: account.name) },
data: { goal_earmark_target: "allocationInput", action: "input->goal-earmark#refresh" },
class: "shrink-0 w-36 rounded-md border border-primary bg-container px-2.5 py-1.5 text-sm text-right tabular-nums text-primary placeholder:text-subdued focus-ring privacy-sensitive" %>
</div>
<%# Never text-destructive: none of this is an error. A goal
in progress legitimately claims more than its account
holds — that is what saving toward something looks like. %>
<p class="mt-1 text-xs text-secondary privacy-sensitive hidden" data-goal-earmark-target="warning"></p>
</div>
<% end %>
</div>
<% end %>
</div>
<p class="<%= "hidden" unless goal.errors[:base].any? %> mt-1.5 text-xs text-destructive" data-goal-form-target="accountsError"><%= t("goals.form.errors.accounts_required") %></p>
<%# Server-side only: GoalAccount refuses a second whole-balance link on an
account another goal already claims in full. There is no client-side
equivalent to mirror, so this renders straight from the record rather
than through a goal-form target (the controller is already at 10). %>
<% goal.errors.where(:"goal_accounts.base").each do |error| %>
<p class="mt-1.5 text-xs text-destructive"><%= error.message %></p>
<% end %>
</div>
<%= f.text_area :notes,
label: t("goals.form.fields.notes"),
rows: 2,
placeholder: t("goals.form.fields.notes_placeholder") %>
<div class="flex justify-end pt-2">
<%= f.submit goal.persisted? ? t("goals.form.save") : t("goals.form.create"),
data: { goal_form_target: "submitButton" } %>
</div>
<% end %>
</div>