From 23e7db4f4d80326577c5b5fde664305dd8a9131e Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Tue, 4 Aug 2026 23:03:12 +0200 Subject: [PATCH] feat(transactions): surface recently-used categories in the category picker (#2829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(transactions): don't crash the rule-prompt flash when clearing a category needs_rule_notification? only checked saved_change_to_category_id? and eligible_for_category_rule?, neither of which accounts for category_id being nil. Clearing a category (Clear category / entryable_attributes category_id: nil) satisfies both, so the caller went on to read transaction.category.name against a nil category and crashed. A rule prompt only makes sense when a category was assigned, not cleared, so bail out early when there's no category to build a rule around. * feat(transactions): surface recently-used categories in the category picker Reframes the recency-vs-muscle-memory question as additive, not either/or: a small "Recent" section pinned above the existing alphabetical list, which stays exactly where it always was below it. Precedent for reordering the primary list by frequency (Office's old adaptive menus, browser-history-style resorting) is a well-known anti-pattern — position drifts under the user's hand. Every picker that does recency well (VS Code's command palette, Spotify, Slack's emoji picker) adds a small separate recent cluster instead. - Category#last_used_at, touched only in TransactionCategoriesController#update — the one place a category is actually hand-picked by a person, as opposed to a rule or import auto-assigning one. - Category.recently_used_for(family:, excluding:, limit:) batches the family-scoped query; dropdowns_controller excludes the already- selected category from the Recent section since it's already pinned to the top of the main list. - "Recent" hides itself the moment a search query is typed — it's a pre-search shortcut, not a second copy of search results. Its rows are force-hidden (not just filtered) so keyboard nav can't land on a row that's invisible only because its ancestor section is hidden. * fix(categories): address review feedback on recent-categories picker - Track last_used_at from every manual assignment path (transaction edit form, categorization wizard bulk-update, create-and-assign), not just the category-picker endpoint. Centralized as Transaction#record_category_usage!, called explicitly from each manual controller action rather than wired to a blanket after_save callback, since rule/import auto-assignment must not count as a "recent" pick. - Give recent-section rows a distinct DOM id (recent_category_option_) from their canonical-list counterpart so aria-activedescendant can't resolve to a hidden duplicate during keyboard nav. - Fix migration to ActiveRecord::Migration[7.2] to match the rest of the repo. - Materialize @recent_categories with .to_a to avoid a redundant query. --- app/controllers/categories_controller.rb | 5 +- .../category/dropdowns_controller.rb | 1 + .../transaction_categories_controller.rb | 3 ++ app/controllers/transactions_controller.rb | 1 + .../controllers/list_filter_controller.js | 20 +++++++- app/models/category.rb | 12 +++++ app/models/entry.rb | 1 + app/models/transaction.rb | 10 ++++ app/views/category/dropdowns/_row.html.erb | 4 +- app/views/category/dropdowns/show.html.erb | 9 ++++ .../locales/views/category/dropdowns/ca.yml | 1 + .../locales/views/category/dropdowns/de.yml | 1 + .../locales/views/category/dropdowns/en.yml | 1 + .../locales/views/category/dropdowns/es.yml | 1 + .../locales/views/category/dropdowns/fr.yml | 1 + .../locales/views/category/dropdowns/hu.yml | 1 + .../locales/views/category/dropdowns/it.yml | 1 + .../locales/views/category/dropdowns/nb.yml | 1 + .../locales/views/category/dropdowns/nl.yml | 1 + .../locales/views/category/dropdowns/pl.yml | 1 + .../views/category/dropdowns/pt-BR.yml | 1 + .../locales/views/category/dropdowns/ro.yml | 1 + .../locales/views/category/dropdowns/ru.yml | 1 + .../locales/views/category/dropdowns/tr.yml | 1 + .../locales/views/category/dropdowns/vi.yml | 1 + .../views/category/dropdowns/zh-CN.yml | 1 + .../views/category/dropdowns/zh-TW.yml | 1 + ...27111051_add_last_used_at_to_categories.rb | 6 +++ db/schema.rb | 4 +- .../category/dropdowns_controller_test.rb | 50 +++++++++++++++++++ .../transaction_categories_controller_test.rb | 33 ++++++++++++ test/models/category_test.rb | 28 +++++++++++ test/models/entry_test.rb | 10 ++++ test/models/transaction_test.rb | 40 +++++++++++++++ 34 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 db/migrate/20260727111051_add_last_used_at_to_categories.rb create mode 100644 test/controllers/category/dropdowns_controller_test.rb create mode 100644 test/controllers/transaction_categories_controller_test.rb 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