mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 08:34:26 +00:00
* fix(goals): stop the first goal being told about earmarks it has none of Linking an account and leaving the amount blank shows "This account will fund whatever is left after the other earmarks". On a first goal there are no other earmarks — so the sentence points at something absent, and it is where a user meets the word for the first time. The row already carries `data-earmarked-by-others`, and it is zero in that case. With the account to itself the hint now says so plainly, and "earmark" appears only where earmarks actually exist — which lets the context do the explaining instead of the vocabulary needing it. Pinned server-side rather than in JS. The branch is a ternary; the failure that matters is the form not handing over the second string, which does not raise — the value reads as undefined and the line renders empty. There is also a test that the two hints stay different, since identical copy would leave the branch doing nothing and the first-time reader back where they started. Nothing runs `test/javascript` — no npm script, no CI step — so a test there would have guarded nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): tell an account claimed in full from one claimed by nobody Review on #3216. The new "this account is yours alone" hint keyed off `earmarked_by_other_goals`, which sums `allocated_amount` — and a whole-account link carries nil, so it contributes zero. An account another goal already claims in full therefore looked exactly like an unclaimed one. The form promised the whole balance, and `whole_account_link_must_be_exclusive` refused the blank allocation on submit. That is worse than the sentence this PR set out to fix: it does not merely describe something absent, it describes something the save then contradicts. The row carries both readings now, because neither can be derived from the other. `whole_account_claimed_by_other_goals?` asks the question the sum cannot answer, and the two share the same row filter so the goal being edited is excluded from both. The two tests added here also move above the first `private`, same as on `define_method`, which is public regardless — but they read as a mistake sitting among the helpers. 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>
200 lines
13 KiB
ERB
200 lines
13 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") %>"
|
|
data-goal-kind-amount-label-value="<%= t("goals.form.fields.target_amount") %>"
|
|
data-goal-kind-balance-label-value="<%= t("goals.form.fields.target_balance") %>"
|
|
data-goal-kind-derivable-value="<%= goal.months_target_derivable? %>">
|
|
<%= 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>
|
|
|
|
<%# In months mode the balance is worked out, not typed. A read-only
|
|
input still reads as something to fill in, sitting beside the months
|
|
that are the actual question — so it is shown as what it is. Only
|
|
when there is spending to work it out from, though: with none, the
|
|
controller leaves the field above in place and this stays hidden. %>
|
|
<div data-goal-kind-target="amountDerived" class="hidden">
|
|
<p class="form-field__label"><%= t("goals.form.fields.target_balance") %></p>
|
|
<p class="mt-1 text-lg font-medium text-primary tabular-nums privacy-sensitive">
|
|
<% if goal.target_amount.to_d.positive? %>
|
|
<%= goal.target_amount_money.format(precision: 0) %>
|
|
<% else %>
|
|
<span class="text-subdued text-sm font-normal"><%= t("goals.form.fields.target_balance_pending") %></span>
|
|
<% end %>
|
|
</p>
|
|
<p class="mt-1 text-xs text-subdued"><%= t("goals.form.fields.target_balance_hint") %></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-whole-balance-alone-value="<%= t("goals.form.earmark.whole_balance_alone") %>"
|
|
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) %>"
|
|
data-whole-account-claimed="<%= whole_account_claimed_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>
|