feat(transactions): surface recently-used categories in the category picker (#2829)

* 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_<id>)
  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.
This commit is contained in:
Guillem Arias Fauste
2026-08-04 23:03:12 +02:00
committed by GitHub
parent 248f6a7716
commit 23e7db4f4d
34 changed files with 249 additions and 5 deletions
@@ -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";