diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index 2f1a1cbaa..90cb3bd9a 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -29,7 +29,10 @@ class CategoriesController < ApplicationController @category = Current.family.categories.new(category_params) if @category.save - @transaction.update(category_id: @category.id) if @transaction + if @transaction + @transaction.update(category_id: @category.id) + @transaction.record_category_usage! + end flash[:notice] = t(".success") diff --git a/app/controllers/category/dropdowns_controller.rb b/app/controllers/category/dropdowns_controller.rb index b8d8e69aa..98a75b7ee 100644 --- a/app/controllers/category/dropdowns_controller.rb +++ b/app/controllers/category/dropdowns_controller.rb @@ -3,6 +3,7 @@ class Category::DropdownsController < ApplicationController def show @categories = categories_scope.to_a.excluding(@selected_category).prepend(@selected_category).compact + @recent_categories = Category.recently_used_for(family: Current.family, excluding: @selected_category).to_a end private diff --git a/app/controllers/transaction_categories_controller.rb b/app/controllers/transaction_categories_controller.rb index d9daf436a..95c5dd731 100644 --- a/app/controllers/transaction_categories_controller.rb +++ b/app/controllers/transaction_categories_controller.rb @@ -9,6 +9,8 @@ class TransactionCategoriesController < ApplicationController transaction = @entry.transaction + transaction.record_category_usage! + if needs_rule_notification?(transaction) flash[:cta] = { type: "category_rule", @@ -54,6 +56,7 @@ class TransactionCategoriesController < ApplicationController def needs_rule_notification?(transaction) return false if Current.user.rule_prompts_disabled + return false if transaction.category_id.blank? if Current.user.rule_prompt_dismissed_at.present? time_since_last_rule_prompt = Time.current - Current.user.rule_prompt_dismissed_at diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index b57a10654..49a61b50b 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -124,6 +124,7 @@ class TransactionsController < ApplicationController def update if @entry.update(permitted_entry_params) transaction = @entry.transaction + transaction.record_category_usage! if needs_rule_notification?(transaction) flash[:cta] = { diff --git a/app/javascript/controllers/list_filter_controller.js b/app/javascript/controllers/list_filter_controller.js index c6938a417..b012f26e5 100644 --- a/app/javascript/controllers/list_filter_controller.js +++ b/app/javascript/controllers/list_filter_controller.js @@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus"; // Basic functionality to filter a list based on a provided text attribute. export default class extends Controller { - static targets = ["input", "list", "emptyMessage"]; + static targets = ["input", "list", "emptyMessage", "recentSection"]; connect() { this.inputTarget.focus(); @@ -15,11 +15,29 @@ export default class extends Controller { const items = this.listTarget.querySelectorAll(".filterable-item"); let noMatchFound = true; + // "Recent" is a pre-search shortcut only — once the user is actively + // searching, show canonical filtered results, not a duplicate row. Its + // items are hidden outright (not text-matched) so the now-ancestor-hidden + // section can't leave a row with display:"" that arrow-key nav would + // still treat as visible. + const recentItems = this.hasRecentSectionTarget + ? new Set(this.recentSectionTarget.querySelectorAll(".filterable-item")) + : new Set(); + + if (this.hasRecentSectionTarget) { + this.recentSectionTarget.classList.toggle("hidden", filterValue.length > 0); + } + if (this.hasEmptyMessageTarget) { this.emptyMessageTarget.classList.add("hidden"); } items.forEach((item) => { + if (filterValue.length > 0 && recentItems.has(item)) { + item.style.display = "none"; + return; + } + const text = item.getAttribute("data-filter-name").toLowerCase(); const shouldDisplay = text.includes(filterValue); item.style.display = shouldDisplay ? "" : "none"; diff --git a/app/models/category.rb b/app/models/category.rb index 7bad10cd5..d57a8905e 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -21,6 +21,7 @@ class Category < ApplicationRecord before_save :inherit_color_from_parent scope :alphabetically, -> { order(:name) } + scope :recently_used, -> { where.not(last_used_at: nil).order(last_used_at: :desc) } scope :alphabetically_by_hierarchy, -> { left_joins(:parent) .order(Arel.sql("COALESCE(parents_categories.name, categories.name)")) @@ -148,6 +149,17 @@ class Category < ApplicationRecord .index_with(true) end + # Categories a family has manually assigned recently — a shortcut above the + # alphabetical list, not a replacement for it. See Transaction#record_category_usage! + # for where last_used_at is touched (only on a real human pick via one of the + # manual assignment controllers, not rule/import auto-assignment). + def recently_used_for(family:, excluding: [], limit: 4) + family.categories + .recently_used + .excluding(Array(excluding).compact) + .limit(limit) + end + def suggested_icon(name) name_down = name.to_s.downcase diff --git a/app/models/entry.rb b/app/models/entry.rb index a29b3b2bd..44ffa15f4 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -487,6 +487,7 @@ class Entry < ApplicationRecord attrs[:entryable_attributes] = attrs[:entryable_attributes].dup if attrs[:entryable_attributes].present? attrs[:entryable_attributes][:id] = entry.entryable_id if attrs[:entryable_attributes].present? entry.update! attrs + entry.transaction.record_category_usage! if entry.transaction? changed = true end end diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 1aec5ef96..0148616f6 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -144,6 +144,16 @@ class Transaction < ApplicationRecord update!(category: category) end + # Marks a category as recently used. Called explicitly from the manual + # category-assignment controllers (picker, edit form, categorization + # wizard, create-and-assign) after a successful save — not wired to a + # blanket after_save callback because rule and import auto-assignment + # also go through `category_id=`, and those shouldn't count as a "recent" + # pick. See Category.recently_used_for. + def record_category_usage! + category.touch(:last_used_at) if saved_change_to_category_id? && category.present? + end + def pending? extra_data = extra.is_a?(Hash) ? extra : {} PENDING_PROVIDERS.any? do |provider| diff --git a/app/views/category/dropdowns/_row.html.erb b/app/views/category/dropdowns/_row.html.erb index 8f133c14b..0a0f41e54 100644 --- a/app/views/category/dropdowns/_row.html.erb +++ b/app/views/category/dropdowns/_row.html.erb @@ -1,8 +1,8 @@ -<%# locals: (category:) %> +<%# locals: (category:, id_prefix: "category_option") %> <% is_selected = category.id === @selected_category&.id %> <%= content_tag :div, - id: dom_id(category, "category_option"), + id: dom_id(category, local_assigns.fetch(:id_prefix, "category_option")), role: "option", aria_selected: is_selected.to_s, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full hover:bg-container-inset-hover", diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb index 27a272ea4..b514dc108 100644 --- a/app/views/category/dropdowns/show.html.erb +++ b/app/views/category/dropdowns/show.html.erb @@ -20,6 +20,15 @@ + <% if @recent_categories.any? %> +
+

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

+ <% @recent_categories.each do |category| %> + <%= render "category/dropdowns/row", category: category, id_prefix: "recent_category_option" %> + <% end %> + <%= render "shared/ruler", classes: "my-2" %> +
+ <% end %> <% if @categories.any? %> <% Category::Group.for(@categories).each do |group| %> <%= render "category/dropdowns/row", category: group.category %> diff --git a/config/locales/views/category/dropdowns/ca.yml b/config/locales/views/category/dropdowns/ca.yml index 6cfdb1b6c..dd79b0cb9 100644 --- a/config/locales/views/category/dropdowns/ca.yml +++ b/config/locales/views/category/dropdowns/ca.yml @@ -8,4 +8,5 @@ ca: show: clear: Esborra la categoria no_categories: No s'han trobat categories + recent: Recents search_placeholder: Cerca diff --git a/config/locales/views/category/dropdowns/de.yml b/config/locales/views/category/dropdowns/de.yml index 1e1d79fdd..fe77f2a78 100644 --- a/config/locales/views/category/dropdowns/de.yml +++ b/config/locales/views/category/dropdowns/de.yml @@ -8,4 +8,5 @@ de: show: clear: Kategorie löschen no_categories: Keine Kategorien gefunden + recent: Zuletzt verwendet search_placeholder: Suchen diff --git a/config/locales/views/category/dropdowns/en.yml b/config/locales/views/category/dropdowns/en.yml index 511e86a9a..f384041cd 100644 --- a/config/locales/views/category/dropdowns/en.yml +++ b/config/locales/views/category/dropdowns/en.yml @@ -8,4 +8,5 @@ en: show: clear: Clear category no_categories: No categories found + recent: Recent search_placeholder: Search diff --git a/config/locales/views/category/dropdowns/es.yml b/config/locales/views/category/dropdowns/es.yml index e7be1bd16..4f1560130 100644 --- a/config/locales/views/category/dropdowns/es.yml +++ b/config/locales/views/category/dropdowns/es.yml @@ -8,4 +8,5 @@ es: show: clear: Limpiar categoría no_categories: No se encontraron categorías + recent: Recientes search_placeholder: Buscar diff --git a/config/locales/views/category/dropdowns/fr.yml b/config/locales/views/category/dropdowns/fr.yml index e577feee2..12af5568a 100644 --- a/config/locales/views/category/dropdowns/fr.yml +++ b/config/locales/views/category/dropdowns/fr.yml @@ -8,4 +8,5 @@ fr: show: clear: Effacer la catégorie no_categories: Aucune catégorie trouvée + recent: Récents search_placeholder: Rechercher diff --git a/config/locales/views/category/dropdowns/hu.yml b/config/locales/views/category/dropdowns/hu.yml index 70be94cf5..390bf389c 100644 --- a/config/locales/views/category/dropdowns/hu.yml +++ b/config/locales/views/category/dropdowns/hu.yml @@ -8,4 +8,5 @@ hu: show: clear: Kategória törlése no_categories: Nem található kategória + recent: Legutóbbiak search_placeholder: Keresés diff --git a/config/locales/views/category/dropdowns/it.yml b/config/locales/views/category/dropdowns/it.yml index 9e7b7bcc2..2a29301bb 100644 --- a/config/locales/views/category/dropdowns/it.yml +++ b/config/locales/views/category/dropdowns/it.yml @@ -8,4 +8,5 @@ it: show: clear: Cancella categoria no_categories: Nessuna categoria trovata + recent: Recenti search_placeholder: Cerca diff --git a/config/locales/views/category/dropdowns/nb.yml b/config/locales/views/category/dropdowns/nb.yml index aa2cfa98b..5304b55b2 100644 --- a/config/locales/views/category/dropdowns/nb.yml +++ b/config/locales/views/category/dropdowns/nb.yml @@ -8,4 +8,5 @@ nb: show: clear: Fjern kategori no_categories: Ingen kategorier funnet + recent: Nylige search_placeholder: Søk \ No newline at end of file diff --git a/config/locales/views/category/dropdowns/nl.yml b/config/locales/views/category/dropdowns/nl.yml index 9ae74d6a8..eb04ecb14 100644 --- a/config/locales/views/category/dropdowns/nl.yml +++ b/config/locales/views/category/dropdowns/nl.yml @@ -8,4 +8,5 @@ nl: show: clear: Categorie wissen no_categories: Geen categorieën gevonden + recent: Recent search_placeholder: Zoeken diff --git a/config/locales/views/category/dropdowns/pl.yml b/config/locales/views/category/dropdowns/pl.yml index 74380bb48..438283f98 100644 --- a/config/locales/views/category/dropdowns/pl.yml +++ b/config/locales/views/category/dropdowns/pl.yml @@ -8,4 +8,5 @@ pl: show: clear: Wyczyść kategorię no_categories: Nie znaleziono kategorii + recent: Ostatnie search_placeholder: Szukaj diff --git a/config/locales/views/category/dropdowns/pt-BR.yml b/config/locales/views/category/dropdowns/pt-BR.yml index 6cde2c64a..adb8c6fe2 100644 --- a/config/locales/views/category/dropdowns/pt-BR.yml +++ b/config/locales/views/category/dropdowns/pt-BR.yml @@ -8,4 +8,5 @@ pt-BR: show: clear: Limpar categoria no_categories: Nenhuma categoria encontrada + recent: Recentes search_placeholder: Buscar diff --git a/config/locales/views/category/dropdowns/ro.yml b/config/locales/views/category/dropdowns/ro.yml index 7b0a5bbf7..8617adb4b 100644 --- a/config/locales/views/category/dropdowns/ro.yml +++ b/config/locales/views/category/dropdowns/ro.yml @@ -8,4 +8,5 @@ ro: show: clear: Golește categoria no_categories: Nu s-au găsit categorii + recent: Recente search_placeholder: Caută diff --git a/config/locales/views/category/dropdowns/ru.yml b/config/locales/views/category/dropdowns/ru.yml index a0f008f82..cb00b95cc 100644 --- a/config/locales/views/category/dropdowns/ru.yml +++ b/config/locales/views/category/dropdowns/ru.yml @@ -8,4 +8,5 @@ ru: show: clear: Очистить категорию no_categories: Категории не найдены + recent: Недавние search_placeholder: Поиск diff --git a/config/locales/views/category/dropdowns/tr.yml b/config/locales/views/category/dropdowns/tr.yml index 3b645f448..0c1eea604 100644 --- a/config/locales/views/category/dropdowns/tr.yml +++ b/config/locales/views/category/dropdowns/tr.yml @@ -8,4 +8,5 @@ tr: show: clear: Kategoriyi temizle no_categories: Hiç kategori bulunamadı + recent: Son kullanılanlar search_placeholder: Ara diff --git a/config/locales/views/category/dropdowns/vi.yml b/config/locales/views/category/dropdowns/vi.yml index 04cf03947..11bd4d2ed 100644 --- a/config/locales/views/category/dropdowns/vi.yml +++ b/config/locales/views/category/dropdowns/vi.yml @@ -8,4 +8,5 @@ vi: show: clear: Xóa danh mục no_categories: Không tìm thấy danh mục nào + recent: Gần đây search_placeholder: Tìm kiếm diff --git a/config/locales/views/category/dropdowns/zh-CN.yml b/config/locales/views/category/dropdowns/zh-CN.yml index 3db41514c..b4da34709 100644 --- a/config/locales/views/category/dropdowns/zh-CN.yml +++ b/config/locales/views/category/dropdowns/zh-CN.yml @@ -8,4 +8,5 @@ zh-CN: show: clear: 清空分类 no_categories: 暂无分类 + recent: 最近使用 search_placeholder: 搜索分类 diff --git a/config/locales/views/category/dropdowns/zh-TW.yml b/config/locales/views/category/dropdowns/zh-TW.yml index 9970c4bd4..70c0a728e 100644 --- a/config/locales/views/category/dropdowns/zh-TW.yml +++ b/config/locales/views/category/dropdowns/zh-TW.yml @@ -8,4 +8,5 @@ zh-TW: show: clear: 清空分類 no_categories: 暫無分類 + recent: 最近使用 search_placeholder: 搜尋分類 diff --git a/db/migrate/20260727111051_add_last_used_at_to_categories.rb b/db/migrate/20260727111051_add_last_used_at_to_categories.rb new file mode 100644 index 000000000..35089c385 --- /dev/null +++ b/db/migrate/20260727111051_add_last_used_at_to_categories.rb @@ -0,0 +1,6 @@ +class AddLastUsedAtToCategories < ActiveRecord::Migration[7.2] + def change + add_column :categories, :last_used_at, :datetime + add_index :categories, [ :family_id, :last_used_at ] + end +end diff --git a/db/schema.rb b/db/schema.rb index 7e94f1dce..c4346da7f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_25_000000) do +ActiveRecord::Schema[7.2].define(version: 2026_07_27_111051) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -394,7 +394,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_07_25_000000) do t.uuid "parent_id" t.string "classification_unused", default: "expense", null: false t.string "lucide_icon", default: "shapes", null: false + t.datetime "last_used_at" t.index ["family_id"], name: "index_categories_on_family_id" + t.index ["family_id", "last_used_at"], name: "index_categories_on_family_id_and_last_used_at" end create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/controllers/category/dropdowns_controller_test.rb b/test/controllers/category/dropdowns_controller_test.rb new file mode 100644 index 000000000..d8e1eb61f --- /dev/null +++ b/test/controllers/category/dropdowns_controller_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class Category::DropdownsControllerTest < ActionDispatch::IntegrationTest + include ActionView::RecordIdentifier + + setup do + sign_in users(:family_admin) + @transaction = transactions(:one) + ensure_tailwind_build + end + + test "shows a recent section for categories used recently" do + recent = categories(:income) + recent.update!(last_used_at: 1.day.ago) + + get category_dropdown_url(transaction_id: @transaction.id) + + assert_response :success + assert_select "[data-list-filter-target='recentSection']" + assert_select "[data-list-filter-target='recentSection']", text: /#{Regexp.escape(recent.name)}/ + end + + test "recent and canonical rows for the same category have distinct DOM ids" do + recent = categories(:income) + recent.update!(last_used_at: 1.day.ago) + + get category_dropdown_url(transaction_id: @transaction.id) + + assert_response :success + assert_select "##{dom_id(recent, 'recent_category_option')}", count: 1 + assert_select "##{dom_id(recent, 'category_option')}", count: 1 + end + + test "excludes the currently selected category from the recent section" do + selected = categories(:food_and_drink) + selected.update!(last_used_at: 1.day.ago) + + get category_dropdown_url(category_id: selected.id, transaction_id: @transaction.id) + + assert_response :success + assert_select "[data-list-filter-target='recentSection']", false + end + + test "omits the recent section entirely when nothing has been used yet" do + get category_dropdown_url(transaction_id: @transaction.id) + + assert_response :success + assert_select "[data-list-filter-target='recentSection']", false + end +end diff --git a/test/controllers/transaction_categories_controller_test.rb b/test/controllers/transaction_categories_controller_test.rb new file mode 100644 index 000000000..116907edc --- /dev/null +++ b/test/controllers/transaction_categories_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class TransactionCategoriesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in users(:family_admin) + @entry = entries(:transaction) + @transaction = transactions(:one) + end + + test "assigning a category touches its last_used_at" do + category = categories(:income) + assert_nil category.last_used_at + + patch transaction_category_url(@entry), + params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } } }, + as: :turbo_stream + + assert_not_nil category.reload.last_used_at + end + + test "clearing a category does not touch any category's last_used_at" do + category = @transaction.category + assert_nil category.last_used_at + + patch transaction_category_url(@entry), + params: { entry: { entryable_type: "Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } }, + as: :turbo_stream + + assert_response :success + assert_nil @transaction.reload.category_id + assert_nil category.reload.last_used_at + end +end diff --git a/test/models/category_test.rb b/test/models/category_test.rb index a12b9790a..637546a56 100644 --- a/test/models/category_test.rb +++ b/test/models/category_test.rb @@ -159,4 +159,32 @@ class CategoryTest < ActiveSupport::TestCase assert lookup.key?(category.id) assert_not lookup.key?(0) end + + test "recently_used_for orders by last_used_at, most recent first" do + older = categories(:income) + newer = categories(:food_and_drink) + older.update!(last_used_at: 2.days.ago) + newer.update!(last_used_at: 1.day.ago) + + assert_equal [ newer, older ], Category.recently_used_for(family: @family).to_a + end + + test "recently_used_for excludes categories with no usage yet" do + categories(:food_and_drink).update!(last_used_at: 1.day.ago) + + assert_not_includes Category.recently_used_for(family: @family).to_a, categories(:income) + end + + test "recently_used_for excludes given categories and respects limit" do + a = categories(:income) + b = categories(:food_and_drink) + c = categories(:subcategory) + a.update!(last_used_at: 3.days.ago) + b.update!(last_used_at: 2.days.ago) + c.update!(last_used_at: 1.day.ago) + + result = Category.recently_used_for(family: @family, excluding: b, limit: 1) + + assert_equal [ c ], result.to_a + end end diff --git a/test/models/entry_test.rb b/test/models/entry_test.rb index bcf820d4d..77c4b036b 100644 --- a/test/models/entry_test.rb +++ b/test/models/entry_test.rb @@ -22,4 +22,14 @@ class EntryTest < ActiveSupport::TestCase assert_equal entry_ids.sort, Entry.where(id: entry_ids).chronological.pluck(:id) assert_equal entry_ids.sort.reverse, Entry.where(id: entry_ids).reverse_chronological.pluck(:id) end + + test "bulk_update! touches the assigned category's last_used_at" do + entry = create_transaction(account: accounts(:depository)) + category = categories(:income) + assert_nil category.last_used_at + + Entry.where(id: entry.id).bulk_update!({ category_id: category.id }) + + assert_not_nil category.reload.last_used_at + end end diff --git a/test/models/transaction_test.rb b/test/models/transaction_test.rb index dfbf8e96b..560d1040f 100644 --- a/test/models/transaction_test.rb +++ b/test/models/transaction_test.rb @@ -179,4 +179,44 @@ class TransactionTest < ActiveSupport::TestCase assert_equal securities(:msft), transaction.activity_security end + + test "record_category_usage! touches the new category's last_used_at" do + transaction = transactions(:one) + category = categories(:income) + assert_nil category.last_used_at + + transaction.update!(category: category) + transaction.record_category_usage! + + assert_not_nil category.reload.last_used_at + end + + test "record_category_usage! does nothing when category_id did not change" do + transaction = transactions(:one) + category = transaction.category + assert_nil category.last_used_at + + transaction.reload + transaction.record_category_usage! + + assert_nil category.reload.last_used_at + end + + test "record_category_usage! does nothing when category is cleared" do + transaction = transactions(:one) + + transaction.update!(category: nil) + + assert_nothing_raised { transaction.record_category_usage! } + end + + test "record_category_usage! is not invoked by rule-driven category enrichment" do + transaction = transactions(:one) + category = categories(:income) + assert_nil category.last_used_at + + transaction.enrich_attribute(:category_id, category.id, source: "rule") + + assert_nil category.reload.last_used_at + end end