From 0870ebb56b0677ddcc99ea052ced1d84b353a98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20M=C3=B8ller?= Date: Tue, 7 Apr 2026 17:24:50 +0800 Subject: [PATCH] Add Quick Categorize Wizard (#1386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Quick Categorize Wizard (iteration 1) Adds a step-by-step wizard for bulk-categorizing uncategorized transactions and optionally creating auto-categorization rules, reducing friction after connecting a new bank account. New files: - Transaction::Grouper abstraction + ByMerchantOrName strategy (groups by merchant name when present, falls back to entry name; sorted by count desc) - Transactions::CategorizesController (GET show / POST create) - Wizard view at app/views/transactions/categorizes/show.html.erb - Stimulus categorize_controller.js (Enter-key-to-select-first) - Tests for grouper and controller Modified files: - routes.rb: resource :categorize inside namespace :transactions - transactions_controller.rb: expose @uncategorized_count to index - transactions/index.html.erb: Categorize (N) button in header - family.rb: uncategorized_transaction_count query - rules_controller.rb: return_to param support for wizard → rule editor flow - rules/_form.html.erb, rules/new.html.erb: pass return_to through form - i18n: categorizes show/create keys + rules.create.success Co-Authored-By: Claude Sonnet 4.6 * Quick Categorize Wizard — iteration 2 polish Six improvements from live testing: - Breadcrumb: Home > Transactions > Categorize - Layout: category picker + confirmation dialog above transaction list - Inline confirmation dialog: clicking a category pill shows a summarising what will happen (N transactions → category, rule if checked) with Confirm and Cancel buttons — no redirect to rule editor - Direct rule creation: rule created with active: true in the controller instead of redirecting to the rule editor; revert return_to plumbing from RulesController, rules/_form, rules/new, rules/en.yml - Individual row assignment: per-row category +
+ " + data-action="change->categorize#uncheckRule"> + <%= entry.name %> + <%= l(entry.date, format: :short) %> + "> + <%= format_money(entry.amount_money.abs) %> + + <%= select_tag "category_id", + options_from_collection_for_select(categories, :id, :name), + prompt: t("transactions.categorizes.show.assign_category_prompt"), + aria: { label: t("transactions.categorizes.entry_row.assign_category_select", name: entry.name) }, + class: "w-full text-xs border border-primary rounded-lg px-1.5 py-0.5 bg-container text-secondary", + data: { entry_id: entry.id, action: "change->categorize#assignEntry" } %> +
+<% end %> diff --git a/app/views/transactions/categorizes/_group_summary.html.erb b/app/views/transactions/categorizes/_group_summary.html.erb new file mode 100644 index 000000000..eeaa0dc14 --- /dev/null +++ b/app/views/transactions/categorizes/_group_summary.html.erb @@ -0,0 +1,7 @@ +<%= turbo_frame_tag "categorize_group_summary" do %> +

+ <%= t("transactions.categorizes.show.transaction_count", count: entries.size) %> + · + <%= format_money(entries.sum { |e| e.amount_money.abs }) %> +

+<% end %> diff --git a/app/views/transactions/categorizes/_group_title.html.erb b/app/views/transactions/categorizes/_group_title.html.erb new file mode 100644 index 000000000..948b504ac --- /dev/null +++ b/app/views/transactions/categorizes/_group_title.html.erb @@ -0,0 +1,10 @@ +<%= turbo_frame_tag "categorize_group_title" do %> +
+

<%= display_name %>

+ <% if transaction_type == "income" %> + <%= t("transactions.categorizes.show.type_income") %> + <% elsif transaction_type == "expense" %> + <%= t("transactions.categorizes.show.type_expense") %> + <% end %> +
+<% end %> diff --git a/app/views/transactions/categorizes/_remaining_count.html.erb b/app/views/transactions/categorizes/_remaining_count.html.erb new file mode 100644 index 000000000..d185e3357 --- /dev/null +++ b/app/views/transactions/categorizes/_remaining_count.html.erb @@ -0,0 +1,5 @@ +<%= turbo_frame_tag "categorize_remaining" do %> + + <%= t("transactions.categorizes.show.remaining", count: total_uncategorized) %> + +<% end %> diff --git a/app/views/transactions/categorizes/_transaction_list.html.erb b/app/views/transactions/categorizes/_transaction_list.html.erb new file mode 100644 index 000000000..d624a0e3c --- /dev/null +++ b/app/views/transactions/categorizes/_transaction_list.html.erb @@ -0,0 +1,20 @@ +<%= turbo_frame_tag "categorize_transaction_list" do %> +
+ <%# Header — same grid template and padding as each row %> +
+
+

<%= t("transactions.categorizes.show.col_transaction") %>

+

<%= t("transactions.categorizes.show.col_date") %>

+

<%= t("transactions.categorizes.show.col_amount") %>

+

<%= t("transactions.categorizes.show.col_category") %>

+
+ <%# Rows %> +
+
+ <% entries.each do |entry| %> + <%= render partial: "transactions/categorizes/entry_row", locals: { entry: entry, categories: categories } %> + <% end %> +
+
+
+<% end %> diff --git a/app/views/transactions/categorizes/show.html.erb b/app/views/transactions/categorizes/show.html.erb new file mode 100644 index 000000000..59d49f61b --- /dev/null +++ b/app/views/transactions/categorizes/show.html.erb @@ -0,0 +1,145 @@ +<%# Wizard step: categorize one group of uncategorized transactions %> +
+ <%# Top bar: remaining count + skip %> +
+ <%= turbo_frame_tag "categorize_remaining" do %> + + <%= t(".remaining", count: @total_uncategorized) %> + + <% end %> + <%= link_to transactions_categorize_path(position: @position + 1), + class: "flex items-center gap-1.5 text-sm font-medium text-secondary hover:text-primary" do %> + <%= t(".skip") %> + <%= icon("arrow-right", size: "sm") %> + <% end %> +
+ + <%# Group identity — above columns %> +
+
+ <%= render partial: "transactions/categorizes/group_title", + locals: { display_name: @group.display_name, color: @group.merchant&.color || "#737373", transaction_type: @group.transaction_type } %> +
+ <%= turbo_frame_tag "categorize_group_summary" do %> +

+ <%= t(".transaction_count", count: @group.entries.size) %> + · + <%= format_money(@group.entries.sum { |e| e.amount_money.abs }) %> +

+ <% end %> +
+ + <%# Main form %> + <%= form_with url: transactions_categorize_path, method: :post, id: "categorize-form", class: "w-full" do |form| %> + <%= form.hidden_field :position, value: @position %> + <%= form.hidden_field :transaction_type, value: @group.transaction_type %> + +
+ <%# Left column (60%): rule creation + transaction list %> +
+ <%# Rule creation %> +
+ + +
1 %>"> + + +

+ <%= t(".rule_description_prefix", type: t(".type_#{@group.transaction_type}").downcase) %> + + "<%= @group.grouping_key %>" + + + + <%= t(".rule_description_suffix") %> +

+
+
+ + <%# Transaction list %> +
+

<%= t(".transactions_hint") %>

+ <%= render partial: "transactions/categorizes/transaction_list", + locals: { entries: @group.entries, categories: @categories } %> +
+
+ + <%# Right column (40%): category picker %> +
+
+

<%= t(".assign_category") %>

+ +
+ <%# Search field %> +
+ <%= icon("search", class: "absolute left-3 text-secondary") %> + " + autocomplete="off" + autofocus + class="w-full pl-9 pr-3 py-2 text-sm bg-transparent border-none rounded-lg focus:outline-none focus:ring-0 placeholder:text-secondary" + data-list-filter-target="input" + data-categorize-target="filter" + data-action="input->list-filter#filter"> +
+ + <%# Category pills — submit the form directly %> +
+ + <% @categories.each do |category| %> + + <% end %> +
+
+
+
+
+ + <% end %> +
diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 671710c06..c56aec7a4 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -11,6 +11,9 @@ <% menu.with_item(variant: "link", text: "Edit merchants", href: family_merchants_path, icon: "store", data: { turbo_frame: :_top }) %> <% menu.with_item(variant: "link", text: "Edit imports", href: imports_path, icon: "hard-drive-upload", data: { turbo_frame: :_top }) %> <% menu.with_item(variant: "link", text: "Import", href: new_import_path, icon: "download", data: { turbo_frame: "modal", class_name: "md:!hidden" }) %> + <% if @uncategorized_count > 0 %> + <% menu.with_item(variant: "link", text: t(".categorize_button", count: @uncategorized_count), href: transactions_categorize_path, icon: "tag", data: { turbo_frame: :_top }) %> + <% end %> <% end %>