From cf4e560a4cb94035dcd857561e0a6ee6a018445f Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 11 May 2026 21:28:23 +0200 Subject: [PATCH] feat(goals): extract shared color_icon_picker controller; add icon to goals; tinted avatar User requested replacing the in-house color disclosure with the categories color+icon popover. Done as a controller extraction so categories and goals share one Stimulus controller (user's option: "Extract a shared color_icon_picker_controller.js"). - `git mv` app/javascript/controllers/category_controller.js to color_icon_picker_controller.js. Categories form + color_avatar partial updated to use the new identifier (data-controller= "color-icon-picker", target/action selectors renamed). - Goal model gains an icon column (migration 20260511190000_add_icon_to_goals.rb) + ICONS = Category.icon_codes + inclusion validation. GoalsController permits :icon in goal_params + goal_update_params. - Goals::AvatarComponent now renders icon when present (falls back to first-letter initial), and adopts the Categories tinted-bg + colored -content style (bg = `color-mix(in oklab, COLOR 10%, transparent)`, text/icon = COLOR). Matches the picker's live preview so what the user sees during selection equals the saved state. - New goals/_color_picker.html.erb mirrors categories/_form's popover: avatar + pen overlay summary + popup with color row (+ rainbow custom-hex trigger) + icon grid. Pickr / contrast validation / auto- adjust all inherited from the shared controller. - Stepper step 1 layout: drop the inline letter-avatar (data-goal- stepper-target="avatarPreview") in favour of the picker avatar next to the name input. Step 1's tail no longer renders a separate color partial. Edit form passes icons local through. Verified live: new goal modal renders 11 color radios (10 presets + custom) + 141 icon radios + pen-summary; categories form still operational (no console errors) under the renamed controller. --- .../goals/avatar_component.html.erb | 10 +- app/components/goals/avatar_component.rb | 14 +- app/controllers/goals_controller.rb | 4 +- ...ler.js => color_icon_picker_controller.js} | 0 app/models/goal.rb | 3 + app/views/categories/_color_avatar.html.erb | 2 +- app/views/categories/_form.html.erb | 32 +- app/views/goals/_color_picker.html.erb | 77 ++- app/views/goals/_form_edit.html.erb | 2 +- app/views/goals/_form_stepper.html.erb | 9 +- audit-shots/01_index_dark_desktop.png | Bin 0 -> 116691 bytes audit-shots/01_index_light_desktop.png | Bin 0 -> 38927 bytes audit-shots/01_index_light_desktop_full.png | Bin 0 -> 114614 bytes audit-shots/02_index_light_mobile.png | Bin 0 -> 47995 bytes audit-shots/03_show_behind_dark.png | Bin 0 -> 116741 bytes audit-shots/03_show_behind_light.png | Bin 0 -> 116122 bytes audit-shots/04_show_on_track_dark.png | Bin 0 -> 121186 bytes audit-shots/04_show_on_track_light.png | Bin 0 -> 116598 bytes audit-shots/05_show_no_target_date_light.png | Bin 0 -> 116598 bytes audit-shots/06_show_reached_light.png | Bin 0 -> 33091 bytes audit-shots/07_show_paused_light.png | Bin 0 -> 82919 bytes audit-shots/08_index_after_reseed_light.png | Bin 0 -> 44565 bytes audit-shots/09_new_modal_step1_light.png | Bin 0 -> 79223 bytes audit-shots/10_new_modal_step1_validation.png | Bin 0 -> 57028 bytes audit-shots/11_show_behind_full.png | Bin 0 -> 35780 bytes audit-shots/12_edit_modal_light.png | Bin 0 -> 31248 bytes audit-shots/13_index_current_light.png | Bin 0 -> 36745 bytes audit-shots/14_empty_state_desktop_light.png | Bin 0 -> 148913 bytes audit-shots/14_empty_state_light.png | Bin 0 -> 31798 bytes audit-shots/_login_state.png | Bin 0 -> 102903 bytes .../chart/01-house-behind-desktop-dark.png | Bin 0 -> 121186 bytes .../chart/01-house-behind-desktop-light.png | Bin 0 -> 116219 bytes audit-shots/chart/01-house-behind-desktop.png | Bin 0 -> 87515 bytes audit-shots/chart/01-house-behind-mobile.png | Bin 0 -> 33168 bytes .../chart/01-house-on_track-desktop-light.png | Bin 0 -> 33827 bytes audit-shots/chart/01b-house-behind-mobile.png | Bin 0 -> 98087 bytes .../chart/01c-house-behind-mobile-375.png | Bin 0 -> 5851 bytes audit-shots/chart/01d-house-mobile-600w.png | Bin 0 -> 57028 bytes audit-shots/chart/01e-house-mobile-375w.png | Bin 0 -> 35438 bytes .../02-vacation-behind-desktop-light.png | Bin 0 -> 111767 bytes .../03-wedding-on_track-desktop-light.png | Bin 0 -> 111746 bytes .../04-emergency-no_target_date-desktop.png | Bin 0 -> 91658 bytes .../chart/05-sabbatical-paused-desktop.png | Bin 0 -> 87328 bytes .../chart/05b-sabbatical-paused-desktop.png | Bin 0 -> 57326 bytes .../chart/06-paidoff-reached-desktop.png | Bin 0 -> 104174 bytes .../chart/06b-paidoff-reached-desktop.png | Bin 0 -> 104124 bytes .../chart/07-laptop-archived-desktop.png | Bin 0 -> 79791 bytes .../E-theme-bug-dark-attr-but-light-chart.png | Bin 0 -> 93481 bytes .../chart/F-empty-goal-no-contribs.png | Bin 0 -> 36745 bytes audit-shots/chart/F-empty-goal.png | Bin 0 -> 21026 bytes audit-shots/chart/resize-600.png | Bin 0 -> 58591 bytes .../20260511190000_add_icon_to_goals.rb | 5 + db/schema.rb | 5 +- savings-audit-refactoring-ui.md | 464 ++++++++++++++++++ 54 files changed, 580 insertions(+), 47 deletions(-) rename app/javascript/controllers/{category_controller.js => color_icon_picker_controller.js} (100%) create mode 100644 audit-shots/01_index_dark_desktop.png create mode 100644 audit-shots/01_index_light_desktop.png create mode 100644 audit-shots/01_index_light_desktop_full.png create mode 100644 audit-shots/02_index_light_mobile.png create mode 100644 audit-shots/03_show_behind_dark.png create mode 100644 audit-shots/03_show_behind_light.png create mode 100644 audit-shots/04_show_on_track_dark.png create mode 100644 audit-shots/04_show_on_track_light.png create mode 100644 audit-shots/05_show_no_target_date_light.png create mode 100644 audit-shots/06_show_reached_light.png create mode 100644 audit-shots/07_show_paused_light.png create mode 100644 audit-shots/08_index_after_reseed_light.png create mode 100644 audit-shots/09_new_modal_step1_light.png create mode 100644 audit-shots/10_new_modal_step1_validation.png create mode 100644 audit-shots/11_show_behind_full.png create mode 100644 audit-shots/12_edit_modal_light.png create mode 100644 audit-shots/13_index_current_light.png create mode 100644 audit-shots/14_empty_state_desktop_light.png create mode 100644 audit-shots/14_empty_state_light.png create mode 100644 audit-shots/_login_state.png create mode 100644 audit-shots/chart/01-house-behind-desktop-dark.png create mode 100644 audit-shots/chart/01-house-behind-desktop-light.png create mode 100644 audit-shots/chart/01-house-behind-desktop.png create mode 100644 audit-shots/chart/01-house-behind-mobile.png create mode 100644 audit-shots/chart/01-house-on_track-desktop-light.png create mode 100644 audit-shots/chart/01b-house-behind-mobile.png create mode 100644 audit-shots/chart/01c-house-behind-mobile-375.png create mode 100644 audit-shots/chart/01d-house-mobile-600w.png create mode 100644 audit-shots/chart/01e-house-mobile-375w.png create mode 100644 audit-shots/chart/02-vacation-behind-desktop-light.png create mode 100644 audit-shots/chart/03-wedding-on_track-desktop-light.png create mode 100644 audit-shots/chart/04-emergency-no_target_date-desktop.png create mode 100644 audit-shots/chart/05-sabbatical-paused-desktop.png create mode 100644 audit-shots/chart/05b-sabbatical-paused-desktop.png create mode 100644 audit-shots/chart/06-paidoff-reached-desktop.png create mode 100644 audit-shots/chart/06b-paidoff-reached-desktop.png create mode 100644 audit-shots/chart/07-laptop-archived-desktop.png create mode 100644 audit-shots/chart/E-theme-bug-dark-attr-but-light-chart.png create mode 100644 audit-shots/chart/F-empty-goal-no-contribs.png create mode 100644 audit-shots/chart/F-empty-goal.png create mode 100644 audit-shots/chart/resize-600.png create mode 100644 db/migrate/20260511190000_add_icon_to_goals.rb create mode 100644 savings-audit-refactoring-ui.md diff --git a/app/components/goals/avatar_component.html.erb b/app/components/goals/avatar_component.html.erb index 1c210577a..7cb9d7a09 100644 --- a/app/components/goals/avatar_component.html.erb +++ b/app/components/goals/avatar_component.html.erb @@ -1,6 +1,10 @@ - diff --git a/app/components/goals/avatar_component.rb b/app/components/goals/avatar_component.rb index b95d2ca2d..562edfee0 100644 --- a/app/components/goals/avatar_component.rb +++ b/app/components/goals/avatar_component.rb @@ -16,20 +16,30 @@ class Goals::AvatarComponent < ApplicationComponent PALETTE[Digest::MD5.hexdigest(name).to_i(16) % PALETTE.size] end - def initialize(goal: nil, name: nil, color: nil, size: "md") + def initialize(goal: nil, name: nil, color: nil, icon: nil, size: "md") @goal = goal @name = name || goal&.name @color = color || goal&.color || Goal::COLORS.first + @icon = icon || goal&.icon @size = SIZES.key?(size) ? size : "md" end - attr_reader :color + attr_reader :color, :icon def initial return "?" if @name.blank? @name.strip.first&.upcase || "?" end + def icon_size + case @size + when "sm" then "xs" + when "md" then "sm" + when "lg" then "md" + when "xl" then "xl" + end + end + def box_classes SIZES[@size][:box] end diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index 3105a4c9a..10f456693 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -154,11 +154,11 @@ class GoalsController < ApplicationController end def goal_params - params.require(:goal).permit(:name, :target_amount, :target_date, :color, :notes) + params.require(:goal).permit(:name, :target_amount, :target_date, :color, :icon, :notes) end def goal_update_params - params.require(:goal).permit(:name, :target_amount, :target_date, :color, :notes) + params.require(:goal).permit(:name, :target_amount, :target_date, :color, :icon, :notes) end def lookup_accounts(ids) diff --git a/app/javascript/controllers/category_controller.js b/app/javascript/controllers/color_icon_picker_controller.js similarity index 100% rename from app/javascript/controllers/category_controller.js rename to app/javascript/controllers/color_icon_picker_controller.js diff --git a/app/models/goal.rb b/app/models/goal.rb index b17deda73..ae819e79c 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -2,11 +2,14 @@ class Goal < ApplicationRecord include AASM, Monetizable COLORS = Category::COLORS + ICONS = Category.icon_codes # Virtual attributes used by the create-modal stepper to capture an # optional initial contribution alongside the goal create payload. attr_accessor :initial_contribution_amount, :initial_contribution_account_id + validates :icon, inclusion: { in: ICONS, allow_nil: true } + belongs_to :family has_many :goal_accounts, dependent: :destroy has_many :linked_accounts, through: :goal_accounts, source: :account diff --git a/app/views/categories/_color_avatar.html.erb b/app/views/categories/_color_avatar.html.erb index b8cde18ae..8e690c3b8 100644 --- a/app/views/categories/_color_avatar.html.erb +++ b/app/views/categories/_color_avatar.html.erb @@ -1,7 +1,7 @@ <%# locals: (category:) %> <%= icon(category.lucide_icon, size: "2xl", color: "current") %> diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 1577f8eb4..115a7d61e 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -1,39 +1,39 @@ <%# locals: (category:, categories:) %> -
+
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
<%= render partial: "color_avatar", locals: { category: category } %> -
+
<%= icon("pen", size: "sm") %> -
-
"> -
+
+
"> +

Color

-
+
<% Category::COLORS.each do |color| %> <% end %>
-
diff --git a/app/views/goals/_color_picker.html.erb b/app/views/goals/_color_picker.html.erb index 35d3d9481..cc152c80c 100644 --- a/app/views/goals/_color_picker.html.erb +++ b/app/views/goals/_color_picker.html.erb @@ -1,17 +1,64 @@ -<%# locals: (form:, colors:) %> -
- - <%= icon "chevron-right", size: "sm", class: "group-open:rotate-90 transition-transform" %> - - <%= t("goals.form_stepper.step1.fields.color") %> - +<%# locals: (form:, colors:, icons:) %> +
+
+ + <% if form.object.icon.present? %> + <%= icon(form.object.icon, color: "current", size: "md") %> + <% else %> + <%= form.object.name.to_s.strip.first&.upcase || "?" %> + <% end %> + -
- <% colors.each do |c| %> - - <% end %> +
+ + <%= icon("pen", size: "xs") %> + + +
+
+
+

Color

+
+ <% colors.each do |c| %> + + <% end %> + +
+ +
+ +
+

Icon

+
+ <% icons.each do |icon_name| %> + + <% end %> +
+
+
+
-
+
diff --git a/app/views/goals/_form_edit.html.erb b/app/views/goals/_form_edit.html.erb index 47bc918e5..236a3b3b7 100644 --- a/app/views/goals/_form_edit.html.erb +++ b/app/views/goals/_form_edit.html.erb @@ -50,7 +50,7 @@
- <%= render "color_picker", form: f, colors: Goal::COLORS %> + <%= render "color_picker", form: f, colors: Goal::COLORS, icons: Goal::ICONS %> <%= f.text_area :notes, label: t("goals.form_stepper.step1.fields.notes"), diff --git a/app/views/goals/_form_stepper.html.erb b/app/views/goals/_form_stepper.html.erb index 7f87a5873..0e921ecc1 100644 --- a/app/views/goals/_form_stepper.html.erb +++ b/app/views/goals/_form_stepper.html.erb @@ -33,10 +33,10 @@
-
- - <%= render Goals::AvatarComponent.new(name: goal.name, color: goal.color, size: "md") %> - +
+
+ <%= render "color_picker", form: f, colors: Goal::COLORS, icons: Goal::ICONS %> +
<%= f.text_field :name, placeholder: t("goals.form_stepper.step1.fields.name_placeholder"), autofocus: true, @@ -104,7 +104,6 @@ placeholder: t("goals.form_stepper.step1.fields.notes_placeholder") %> <% end %> - <%= render "color_picker", form: f, colors: Goal::COLORS %>