Correctness:
- GoalPledge#matches? rejects outflows on transfer pledges so a +$200
purchase no longer satisfies a $200 deposit pledge after .abs
- GoalsController#sync_linked_accounts! saves through the goal so
currency/depository/family validations actually run on update
- AlreadyClaimedError replaces empty RecordInvalid in resolve_with! and
reconciler rescues the dedicated class
- SweepExpiredGoalPledgesJob wraps each expire! in a per-record rescue
- Assistant::Function::CreateGoal disambiguates duplicate account names
and returns an absolute URL via mailer host config
- Family#savings_inflow_velocity defensively scopes from the family's
accounts (was Account.joins(:goal_accounts).where(goal_id: ...))
- GoalPledgesController#set_goal preloads linked_accounts + providers
to drop the N+1 on any_connected_account?
- Stepper subtitle update walks to the enclosing dialog before
querySelector so two stepper instances don't fight over one header
- categories/_form.html.erb data-action targets color-icon-picker, not
the non-existent "category" controller
UX / visual:
- Projection chart drops preserveAspectRatio="none" and pins endDate at
today for past-due goals so the today marker stays in-domain
- _color_picker / categories form swap non-standard border-1 for border
- Goals index search input uses ring-alpha-black-100 (was raw gray-500)
Refactors:
- Goal#header_summary extracts the multi-line ERB header block
- Goal#catch_up_delta_money sums open_pledges in SQL
- Goal#projection_summary uses I18n.l for the on-track month label
- Account#default_pledge_kind moves the manual/transfer decision out of
GoalPledgesController
- GoalPledge::Reconciler iterates ordered (created_at, id) so first-claim
wins is deterministic under non-sequential PKs
- Goals::FundingAccountsBreakdownComponent + Goals::AccountStackComponent
use clamp(0..) instead of Float::INFINITY / [x, 0].max
- Goals::StatusPillComponent#label provides a titleize fallback
- Goal projection chart skips the redundant initial _draw and reuses
the snapped point in the past branch (no double-bisect)
- Goal pledge preview drops maximumFractionDigits: 0 so USD/EUR show
cents while JPY/KRW stay whole-unit
- Demo generator captures the Wedding fund goal in the seed loop
instead of looking it up by hardcoded name
Tests:
- GoalPledgeTest: outflow rejection
- GoalsControllerTest: cross-currency attachment rejected on update
- SweepExpiredGoalPledgesJobTest: cancelled coverage + per-record rescue
- GoalTest: pledge_action_label_key flips to manual_save without an
unconditional guard
User flagged two regressions: account colors didn't match between the
goal preview-card avatar stack on the index and the funding-widget
rows on the show page, and the color-picker pen toggle on the new-goal
modal still felt too big.
Color matching:
- `AccountStackComponent` (index card) used
`Goals::AvatarComponent.color_for(account.name)` — MD5-of-name into
the 10-color palette.
- `FundingAccountsBreakdownComponent` (show page) recently switched to
`color_for(account.id.to_s)` — MD5-of-id.
- Same account, two surfaces, two different palette picks. Plus
either hashing scheme can collide within a multi-account goal
(palette has 10 colors).
Move ownership to the Goal model: `Goal#account_color_map` returns
`{ account_id => palette_hex }` for the goal's linked accounts. Sort
by `id` for a stable order across reloads, then assign
`palette[i % palette.size]`. Stable + collision-free up to 10
accounts in a single goal (a realistic upper bound — most goals
link 1-3).
Both consumers now read off the same source:
- `AccountStackComponent.new(accounts:, color_map:)` accepts a hash
and falls back to the name-hash if no map provided (kept for
callers that don't have a goal in scope yet).
- `FundingAccountsBreakdownComponent#color_for` reads
`goal.account_color_map[account.id]`.
- Goal card on index passes `goal.account_color_map` to the stack.
Pen toggle:
The new-goal color-picker pen sat in a `w-5 h-5` circle with a
`border` ring + `text-secondary` icon. The border + secondary text
weight kept it loud against the avatar even at 20px. Drop the
border, drop the size another step (`w-4 h-4`), recolor the icon
`text-subdued` + `hover:text-secondary` so the affordance recedes
when not interacted with. Position shifts from `-bottom-1 -right-1`
(8px overhang) to `-bottom-0.5 -right-0.5` (2px overhang) since the
smaller circle doesn't need the larger float. Icon swaps "pen" for
"pencil" (the more conventional edit indicator across Sure).