Files
sure/app/models/category.rb
T
Guillem Arias Fauste 23e7db4f4d 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.
2026-08-04 23:03:12 +02:00

373 lines
15 KiB
Ruby

class Category < ApplicationRecord
has_many :transactions, dependent: :nullify, class_name: "Transaction"
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
belongs_to :family
has_many :budget_categories, dependent: :destroy
has_many :subcategories,
-> { order(:name) },
class_name: "Category",
foreign_key: :parent_id,
dependent: :nullify
belongs_to :parent, class_name: "Category", optional: true
validates :name, :color, :lucide_icon, :family, presence: true
validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }
validates :name, uniqueness: { scope: :family_id }
validate :category_level_limit
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)"))
.order(Arel.sql("parents_categories.name IS NOT NULL"))
.order(:name)
}
scope :roots, -> { where(parent_id: nil) }
# Legacy scopes - classification removed; these now return all categories
scope :incomes, -> { all }
scope :expenses, -> { all }
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
UNCATEGORIZED_COLOR = "#737373"
OTHER_INVESTMENTS_COLOR = "#e99537"
TRANSFER_COLOR = "#444CE7"
PAYMENT_COLOR = "#db5a54"
TRADE_COLOR = "#e99537"
ICON_KEYWORDS = {
/income|salary|paycheck|wage|earning/ => "circle-dollar-sign",
/groceries|grocery|supermarket/ => "shopping-bag",
/food|dining|restaurant|meal|lunch|dinner|breakfast/ => "utensils",
/coffee|cafe|café/ => "coffee",
/shopping|retail/ => "shopping-cart",
/transport|transit|commute|subway|metro/ => "bus",
/parking/ => "circle-parking",
/car|auto|vehicle/ => "car",
/gas|fuel|petrol/ => "fuel",
/flight|airline/ => "plane",
/travel|trip|vacation|holiday/ => "plane",
/hotel|lodging|accommodation/ => "hotel",
/movie|cinema|film|theater|theatre/ => "film",
/music|concert/ => "music",
/game|gaming/ => "gamepad-2",
/entertainment|leisure/ => "drama",
/sport|fitness|gym|workout|exercise/ => "dumbbell",
/pharmacy|drug|medicine|pill|medication|dental|dentist/ => "pill",
/health|medical|clinic|doctor|physician/ => "stethoscope",
/personal care|beauty|salon|spa|hair/ => "scissors",
/mortgage|rent/ => "home",
/home|house|apartment|housing/ => "home",
/improvement|renovation|remodel/ => "hammer",
/repair|maintenance/ => "wrench",
/electric|power|energy/ => "zap",
/water|sewage/ => "waves",
/internet|cable|broadband|subscription|streaming/ => "wifi",
/utilities|utility/ => "lightbulb",
/phone|telephone/ => "phone",
/mobile|cell/ => "smartphone",
/insurance/ => "shield",
/gift|present/ => "gift",
/donat|charity|nonprofit/ => "hand-helping",
/tax|irs|revenue/ => "landmark",
/loan|debt|credit card/ => "credit-card",
/service|professional/ => "briefcase",
/fee|charge/ => "receipt",
/bank|banking/ => "landmark",
/saving/ => "piggy-bank",
/invest|stock|fund|portfolio/ => "trending-up",
/pet|dog|cat|animal|vet/ => "paw-print",
/education|school|university|college|tuition/ => "graduation-cap",
/book|reading|library/ => "book",
/child|kid|baby|infant|daycare/ => "baby",
/cloth|apparel|fashion|wear/ => "shirt",
/ticket/ => "ticket"
}.freeze
# Category name keys for i18n
UNCATEGORIZED_NAME_KEY = "models.category.uncategorized"
OTHER_INVESTMENTS_NAME_KEY = "models.category.other_investments"
INVESTMENT_CONTRIBUTIONS_NAME_KEY = "models.category.investment_contributions"
DEFAULT_CATEGORY_TRANSLATION_KEYS = %w[
income
food_and_drink
groceries
shopping
transportation
travel
entertainment
healthcare
personal_care
home_improvement
mortgage_rent
utilities
subscriptions
insurance
sports_and_fitness
gifts_and_donations
taxes
loan_payments
services
fees
savings_and_investments
].freeze
class Group
attr_reader :category, :subcategories
delegate :name, :color, to: :category
def self.for(categories)
categories_by_parent_id = categories.to_a.group_by(&:parent_id)
categories_by_parent_id[nil].to_a.map do |category|
new(category, categories_by_parent_id[category.id].to_a)
end
end
def initialize(category, subcategories = nil)
@category = category
@subcategories = subcategories || []
end
end
class << self
def ids_with_transactions(family:, category_ids:)
category_ids = Array(category_ids).compact
return {} if category_ids.empty?
family.transactions
.where(category_id: category_ids)
.distinct
.pluck(:category_id)
.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
ICON_KEYWORDS.each do |pattern, icon|
return icon if name_down.match?(pattern)
end
"shapes"
end
def icon_codes
%w[
ambulance apple award baby badge-dollar-sign banknote barcode bar-chart-3 bath
battery bed-single beer bike bluetooth bone book book-open briefcase building bus
cake calculator calendar-heart calendar-range camera car cat chart-line
circle-dollar-sign circle-parking coffee coins compass cookie cooking-pot
credit-card dices dog drama drill droplet drum dumbbell film flame flower flower-2
fuel gamepad-2 gem gift glasses globe graduation-cap hammer hand-heart
hand-helping heart-handshake handshake headphones heart heart-pulse home hotel
house ice-cream-cone key landmark laptop leaf lightbulb luggage mail map-pin
martini mic monitor moon music package palette party-popper paw-print pen pencil
percent phone pie-chart piggy-bank pill pizza plane plug popcorn power printer
puzzle receipt receipt-text ribbon scale scissors settings shield shield-plus
shirt shopping-bag shopping-basket shopping-cart smartphone sparkles sprout
stethoscope store sun tablet-smartphone tag target tent thermometer ticket train
trees tree-palm trending-up trophy truck tv umbrella undo-2 unplug users utensils
video wallet wallet-cards waves wifi wine wrench zap
]
end
def bootstrap!
default_categories.each do |name, color, icon|
find_or_create_by!(name: name) do |category|
category.color = color
category.lucide_icon = icon
end
end
end
def uncategorized
new(
name: I18n.t(UNCATEGORIZED_NAME_KEY),
color: UNCATEGORIZED_COLOR,
lucide_icon: "circle-dashed"
)
end
def other_investments
new(
name: I18n.t(OTHER_INVESTMENTS_NAME_KEY),
color: OTHER_INVESTMENTS_COLOR,
lucide_icon: "trending-up"
)
end
# Helper to get the localized name for uncategorized
def uncategorized_name
I18n.t(UNCATEGORIZED_NAME_KEY)
end
# Returns all possible uncategorized names across all supported locales
# Used to detect uncategorized filter regardless of URL parameter language
def all_uncategorized_names
LanguagesHelper::SUPPORTED_LOCALES.map do |locale|
I18n.t(UNCATEGORIZED_NAME_KEY, locale: locale)
end.uniq
end
# Helper to get the localized name for other investments
def other_investments_name
I18n.t(OTHER_INVESTMENTS_NAME_KEY)
end
# Helper to get the localized name for investment contributions
def investment_contributions_name
I18n.t(INVESTMENT_CONTRIBUTIONS_NAME_KEY)
end
# Returns all possible investment contributions names across all supported locales
# Used to detect investment contributions category regardless of locale
def all_investment_contributions_names
LanguagesHelper::SUPPORTED_LOCALES.map do |locale|
I18n.t(INVESTMENT_CONTRIBUTIONS_NAME_KEY, locale: locale)
end.uniq
end
def localized_default_name_for(name)
i18n_key = default_category_translation_key_for(name)
i18n_key ? I18n.t(i18n_key, default: name) : name
end
private
def default_category_translation_key_for(name)
default_category_translation_keys_by_name[name.to_s]
end
def default_category_translation_keys_by_name
@default_category_translation_keys_by_name ||= begin
# Default categories store the translated name in the `name` column, so
# older families may have default names from any supported locale. This
# display-layer bridge maps those known labels back to their i18n key
# before rendering in the current locale. A future schema-level
# default_key would remove the ambiguity with user-created categories.
i18n_keys = DEFAULT_CATEGORY_TRANSLATION_KEYS.index_with { |key| "models.category.defaults.#{key}" }
i18n_keys["uncategorized"] = UNCATEGORIZED_NAME_KEY
i18n_keys["other_investments"] = OTHER_INVESTMENTS_NAME_KEY
i18n_keys["investment_contributions"] = INVESTMENT_CONTRIBUTIONS_NAME_KEY
LanguagesHelper::SUPPORTED_LOCALES.each_with_object({}) do |locale, mapping|
i18n_keys.each_value do |i18n_key|
translated_name = I18n.t(i18n_key, locale: locale, default: nil)
mapping[translated_name.to_s] ||= i18n_key if translated_name.present?
end
end
end
end
def default_categories
[
[ I18n.t("models.category.defaults.income"), "#22c55e", "circle-dollar-sign" ],
[ I18n.t("models.category.defaults.food_and_drink"), "#f97316", "utensils" ],
[ I18n.t("models.category.defaults.groceries"), "#407706", "shopping-bag" ],
[ I18n.t("models.category.defaults.shopping"), "#3b82f6", "shopping-cart" ],
[ I18n.t("models.category.defaults.transportation"), "#0ea5e9", "bus" ],
[ I18n.t("models.category.defaults.travel"), "#2563eb", "plane" ],
[ I18n.t("models.category.defaults.entertainment"), "#a855f7", "drama" ],
[ I18n.t("models.category.defaults.healthcare"), "#4da568", "pill" ],
[ I18n.t("models.category.defaults.personal_care"), "#14b8a6", "scissors" ],
[ I18n.t("models.category.defaults.home_improvement"), "#d97706", "hammer" ],
[ I18n.t("models.category.defaults.mortgage_rent"), "#b45309", "home" ],
[ I18n.t("models.category.defaults.utilities"), "#eab308", "lightbulb" ],
[ I18n.t("models.category.defaults.subscriptions"), "#6366f1", "wifi" ],
[ I18n.t("models.category.defaults.insurance"), "#0284c7", "shield" ],
[ I18n.t("models.category.defaults.sports_and_fitness"), "#10b981", "dumbbell" ],
[ I18n.t("models.category.defaults.gifts_and_donations"), "#61c9ea", "hand-helping" ],
[ I18n.t("models.category.defaults.taxes"), "#dc2626", "landmark" ],
[ I18n.t("models.category.defaults.loan_payments"), "#e11d48", "credit-card" ],
[ I18n.t("models.category.defaults.services"), "#7c3aed", "briefcase" ],
[ I18n.t("models.category.defaults.fees"), "#6b7280", "receipt" ],
[ I18n.t("models.category.defaults.savings_and_investments"), "#059669", "piggy-bank" ],
[ investment_contributions_name, "#0d9488", "trending-up" ]
]
end
end
def inherit_color_from_parent
self.color = parent.color if subcategory? && parent
end
def replace_and_destroy!(replacement)
transaction do
transactions.update_all category_id: replacement&.id
destroy!
end
end
def parent?
if association(:subcategories).loaded?
subcategories.any?
else
subcategories.exists?
end
end
def subcategory?
parent_id.present? && parent.present?
end
def name_with_parent
return name unless subcategory?
parent_name = parent&.name
parent_name.present? ? "#{parent_name} > #{name}" : name
end
def display_name
self.class.localized_default_name_for(name)
end
def display_name_with_parent
subcategory? ? "#{parent.display_name} > #{display_name}" : display_name
end
# Predicate: is this the synthetic "Uncategorized" category?
def uncategorized?
!persisted? && name == I18n.t(UNCATEGORIZED_NAME_KEY)
end
# Predicate: is this the synthetic "Other Investments" category?
def other_investments?
!persisted? && name == I18n.t(OTHER_INVESTMENTS_NAME_KEY)
end
# Predicate: is this any synthetic (non-persisted) category?
def synthetic?
uncategorized? || other_investments?
end
private
def category_level_limit
if (subcategory? && parent&.subcategory?) || (parent? && subcategory?)
errors.add(:parent, "can't have more than 2 levels of subcategories")
end
end
def monetizable_currency
family.currency
end
end