Add Quick Categorize Wizard (#1386)

* 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 <noreply@anthropic.com>

* 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 <dialog>
  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 <select> submits via
  PATCH /transactions/categorize/assign_entry and removes the row via
  Turbo Stream (assign_entry action + route)
- Enter key guard: selectFirst only fires when exactly 1 pill is visible
  after filtering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Quick Categorize Wizard — iteration 3 reliability fixes and UX polish

- Fix Stimulus controller not loading: remove invalid `@hotwired/turbo` named
  import (not in importmap); use global `Turbo.renderStreamMessage` instead
- Fix Enter key submitting form with wrong category when search field is
  unfocused: move keydown listener to document so it fires regardless of focus
- Prevent Enter from submitting when multiple categories are visible
- Clear search filter after bulk category assignment (pill click or Enter),
  but not after individual row dropdown assignment
- Update group transaction count and total amount live as entries are assigned
  via row dropdown or partial bulk assignment
- Add turbo frames for remaining count and group summary so they update
  without a full page reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Quick categorization polish

* refactoring

* Remove unused GROUPS_PER_BATCH constant, fix ERB self-closing tags

Wizard only ever uses one group at a time so limit: 1 is correct and
more honest than fetching 20 and discarding 19. ERB linter fixes are
whitespace/void-element corrections with no functional change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Move Categorize button into ... menu on transactions index

Reduces header clutter by putting it in the overflow menu at the bottom,
where it only appears when there are uncategorized transactions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Scope categorize wizard to accessible entries only

Fixes a security issue where users with restricted account access via
account sharing could view and categorize transactions from accounts
they cannot access through normal transaction flows.

- Pass Current.accessible_entries to Transaction::Grouper so the wizard
  only displays groups from accounts the user can see
- Use Current.accessible_entries on all write paths in create and
  assign_entry, matching the pattern in TransactionCategoriesController
- Refactor Grouper to accept an entries scope instead of a family object,
  keeping authorization concerns in the controller
- Add tests verifying inaccessible entries are hidden from the wizard
  and cannot be categorized via forged POST/PATCH params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Clamp position param to >= 0 to guard against negative offset

Prevents ArgumentError from Array#drop when a negative position is
passed via a tampered query string or form value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Surface rule creation failure and add accessible names to entry row

- Capture Rule.create_from_grouping! return value; set flash[:alert] when
  nil so users who checked "Create Rule" know it wasn't created (e.g. a
  duplicate already exists); stream the notification for partial updates
- Add aria-label to the per-row checkbox and category select in
  _entry_row so screen readers can identify which transaction each
  control belongs to

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Localize breadcrumb labels in categorizes controller

Follows the pattern used by FamilyExportsController and ImportsController.
Adds 'transactions' and 'categorize' keys to the breadcrumbs locale file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add error handling to categorize controller fetch calls

Check response.ok before parsing the body and add .catch handlers
so network failures and non-2xx responses are logged rather than
silently swallowed. On assignment failure the per-row select is
reset to empty so the user can retry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Scope preview_rule to accessible entries only

Entry.uncategorized_matching now accepts an entries scope instead of a
family object, matching the same pattern used for Transaction::Grouper.
The preview_rule action passes Current.accessible_entries so rule
previews respect account sharing permissions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Scope remaining count to accessible entries

Adds Entry.uncategorized_count(entries) following the same pattern as
uncategorized_matching. Replaces all three uses of
Current.family.uncategorized_transaction_count in the categorize
controller so the remaining-count badge reflects only the transactions
the current user can actually access and categorize.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Comments got separated from their function

* Remove quick-categorize-wizard dev notes

This was a planning document used during development, not intended
for the final branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Recompute remaining entries from server state after writes

Adds uncategorized_entries_for helper that reloads remaining entries
from the DB with a category_id IS NULL filter after each write, so
the partial-update Turbo Stream reflects server-side state rather than
trusting the client-provided remaining_ids. This handles the case where
a concurrent request has categorized one of the remaining entries
between page render and form submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Rename create_from_grouping! to create_from_grouping

The method rescues RecordInvalid and returns nil, which contradicts
the bang convention. Dropping the ! correctly signals that callers
should check the return value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Clamp offset in grouper to guard against negative values

The controller already clamps position before passing it as offset,
but clamping in the grouper itself prevents ArgumentError from
Array#drop if the grouper is ever called directly with a negative offset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <jjmata@jjmata.com>
This commit is contained in:
Mikael Møller
2026-04-07 17:24:50 +08:00
committed by GitHub
parent df2863ea7b
commit 0870ebb56b
21 changed files with 1090 additions and 7 deletions

View File

@@ -0,0 +1,131 @@
class Transactions::CategorizesController < ApplicationController
def show
@breadcrumbs = [
[ t("breadcrumbs.home"), root_path ],
[ t("breadcrumbs.transactions"), transactions_path ],
[ t("breadcrumbs.categorize"), nil ]
]
@position = [ params[:position].to_i, 0 ].max
groups = Transaction::Grouper.strategy.call(
Current.accessible_entries,
limit: 1,
offset: @position
)
if groups.empty?
redirect_to transactions_path, notice: t(".all_done") and return
end
@group = groups.first
@categories = Current.family.categories.alphabetically
@total_uncategorized = Entry.uncategorized_count(Current.accessible_entries)
end
def create
@position = params[:position].to_i
entry_ids = Array.wrap(params[:entry_ids]).reject(&:blank?)
all_entry_ids = Array.wrap(params[:all_entry_ids]).reject(&:blank?)
remaining_ids = all_entry_ids - entry_ids
category = Current.family.categories.find(params[:category_id])
entries = Current.accessible_entries.excluding_split_parents.where(id: entry_ids)
count = entries.bulk_update!({ category_id: category.id })
if params[:create_rule] == "1"
rule = Rule.create_from_grouping(
Current.family,
params[:grouping_key],
category,
transaction_type: params[:transaction_type]
)
flash[:alert] = t(".rule_creation_failed") if rule.nil?
end
respond_to do |format|
format.turbo_stream do
remaining_entries = uncategorized_entries_for(remaining_ids)
remaining_ids = remaining_entries.map { |e| e.id.to_s }
if remaining_ids.empty?
render turbo_stream: turbo_stream.action(:redirect, transactions_categorize_path(position: @position))
else
@categories = Current.family.categories.alphabetically
streams = entry_ids.map { |id| turbo_stream.remove("categorize_entry_#{id}") }
remaining_entries.each do |entry|
streams << turbo_stream.replace(
"categorize_entry_#{entry.id}",
partial: "transactions/categorizes/entry_row",
locals: { entry: entry, categories: @categories }
)
end
streams << turbo_stream.replace("categorize_remaining",
partial: "transactions/categorizes/remaining_count",
locals: { total_uncategorized: Entry.uncategorized_count(Current.accessible_entries) })
streams << turbo_stream.replace("categorize_group_summary",
partial: "transactions/categorizes/group_summary",
locals: { entries: remaining_entries })
streams.concat(flash_notification_stream_items)
render turbo_stream: streams
end
end
format.html { redirect_to transactions_categorize_path(position: @position), notice: t(".categorized", count: count) }
end
end
def preview_rule
filter = params[:filter].to_s.strip
transaction_type = params[:transaction_type].presence
entries = filter.present? ? Entry.uncategorized_matching(Current.accessible_entries, filter, transaction_type) : []
@categories = Current.family.categories.alphabetically
render turbo_stream: [
turbo_stream.replace("categorize_group_title",
partial: "transactions/categorizes/group_title",
locals: { display_name: filter.presence || "", color: "#737373", transaction_type: transaction_type }),
turbo_stream.replace("categorize_group_summary",
partial: "transactions/categorizes/group_summary",
locals: { entries: entries }),
turbo_stream.replace("categorize_transaction_list",
partial: "transactions/categorizes/transaction_list",
locals: { entries: entries, categories: @categories })
]
end
def assign_entry
entry = Current.accessible_entries.excluding_split_parents.find(params[:entry_id])
category = Current.family.categories.find(params[:category_id])
position = params[:position].to_i
all_entry_ids = Array.wrap(params[:all_entry_ids]).reject(&:blank?)
remaining_ids = all_entry_ids - [ entry.id.to_s ]
Entry.where(id: entry.id).bulk_update!({ category_id: category.id })
remaining_entries = uncategorized_entries_for(remaining_ids)
remaining_ids = remaining_entries.map { |e| e.id.to_s }
streams = [ turbo_stream.remove("categorize_entry_#{entry.id}") ]
if remaining_ids.empty?
streams << turbo_stream.action(:redirect, transactions_categorize_path(position: position))
else
streams << turbo_stream.replace("categorize_remaining",
partial: "transactions/categorizes/remaining_count",
locals: { total_uncategorized: Entry.uncategorized_count(Current.accessible_entries) })
streams << turbo_stream.replace("categorize_group_summary",
partial: "transactions/categorizes/group_summary",
locals: { entries: remaining_entries })
end
render turbo_stream: streams
end
private
def uncategorized_entries_for(ids)
return [] if ids.blank?
Current.accessible_entries
.excluding_split_parents
.where(id: ids)
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(transactions: { category_id: nil })
.to_a
end
end

View File

@@ -52,6 +52,8 @@ class TransactionsController < ApplicationController
Set.new
end
@uncategorized_count = Current.family.uncategorized_transaction_count
# Load projected recurring transactions for next 10 days
@projected_recurring = Current.family.recurring_transactions
.accessible_by(Current.user)

View File

@@ -0,0 +1,165 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [
"list",
"createRuleCheckbox",
"filterDisplay",
"filterEditTrigger",
"filterEditArea",
"filterInput",
"groupingKeyHidden",
"filter",
"ruleDetails",
];
static values = { assignEntryUrl: String, position: Number, previewRuleUrl: String, transactionType: String };
connect() {
this.boundSelectFirst = this.selectFirst.bind(this);
document.addEventListener("keydown", this.boundSelectFirst);
this.toggleRuleDetails();
}
disconnect() {
document.removeEventListener("keydown", this.boundSelectFirst);
clearTimeout(this._previewTimer);
}
selectFirst(event) {
if (event.key !== "Enter") return;
const tag = event.target.tagName;
if (tag === "BUTTON" || tag === "A") return;
// Don't intercept Enter when the user is confirming an inline filter edit
if (this.hasFilterInputTarget && event.target === this.filterInputTarget) return;
event.preventDefault();
const visible = Array.from(
this.listTarget.querySelectorAll(".filterable-item")
).filter((el) => el.style.display !== "none");
if (visible.length !== 1) return;
visible[0].click();
}
clearFilter(event) {
if (event.target.tagName !== "BUTTON") return;
if (!this.hasFilterTarget) return;
this.filterTarget.value = "";
this.filterTarget.dispatchEvent(new Event("input"));
}
uncheckRule() {
if (this.hasCreateRuleCheckboxTarget) {
this.createRuleCheckboxTarget.checked = false;
this.toggleRuleDetails();
}
}
toggleRuleDetails() {
if (!this.hasRuleDetailsTarget || !this.hasCreateRuleCheckboxTarget) return;
const enabled = this.createRuleCheckboxTarget.checked;
this.ruleDetailsTarget.classList.toggle("opacity-40", !enabled);
if (this.hasFilterInputTarget) {
this.filterInputTarget.disabled = !enabled;
}
}
startFilterEdit() {
this.filterDisplayTarget.classList.add("hidden");
this.filterEditTriggerTarget.classList.add("hidden");
this.filterEditAreaTarget.classList.remove("hidden");
this.filterEditAreaTarget.classList.add("flex");
this.filterInputTarget.focus();
this.filterInputTarget.select();
}
confirmFilterEdit(event) {
event.preventDefault();
event.stopPropagation();
const value = this.filterInputTarget.value.trim();
if (!value) return;
this.filterDisplayTarget.textContent = `"${value}"`;
this.groupingKeyHiddenTarget.value = value;
this.filterEditAreaTarget.classList.add("hidden");
this.filterEditAreaTarget.classList.remove("flex");
this.filterDisplayTarget.classList.remove("hidden");
this.filterEditTriggerTarget.classList.remove("hidden");
this._doPreviewRule(value);
}
cancelFilterEdit(event) {
event.preventDefault();
event.stopPropagation();
this.filterEditAreaTarget.classList.add("hidden");
this.filterEditAreaTarget.classList.remove("flex");
this.filterDisplayTarget.classList.remove("hidden");
this.filterEditTriggerTarget.classList.remove("hidden");
}
previewRule(event) {
this._doPreviewRule(event.target.value);
}
_doPreviewRule(filter) {
clearTimeout(this._previewTimer);
this._previewTimer = setTimeout(() => {
const url = new URL(this.previewRuleUrlValue, window.location.origin);
url.searchParams.set("filter", filter);
url.searchParams.set("position", this.positionValue);
url.searchParams.set("transaction_type", this.transactionTypeValue);
fetch(url.toString(), {
credentials: "same-origin",
headers: {
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content,
Accept: "text/vnd.turbo-stream.html",
},
})
.then((r) => { if (!r.ok) throw new Error(r.statusText); return r.text(); })
.then((html) => Turbo.renderStreamMessage(html))
.catch((err) => console.error("Rule preview failed:", err));
}, 300);
}
assignEntry(event) {
const select = event.target;
const categoryId = select.value;
if (!categoryId) return;
this.uncheckRule();
const entryId = select.dataset.entryId;
const body = new FormData();
body.append("entry_id", entryId);
body.append("category_id", categoryId);
body.append("position", this.positionValue);
// all_entry_ids[] hidden inputs live inside each Turbo Frame —
// automatically stay in sync as frames are removed
this.element.querySelectorAll("input[name='all_entry_ids[]']").forEach((input) => {
body.append("all_entry_ids[]", input.value);
});
fetch(this.assignEntryUrlValue, {
method: "PATCH",
credentials: "same-origin",
headers: {
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content,
Accept: "text/vnd.turbo-stream.html",
},
body,
})
.then((r) => { if (!r.ok) throw new Error(r.statusText); return r.text(); })
.then((html) => Turbo.renderStreamMessage(html))
.catch((err) => {
console.error("Entry assignment failed:", err);
select.value = "";
});
}
}

View File

@@ -91,6 +91,43 @@ class Entry < ApplicationRecord
joins(:account).where(accounts: { family_id: family.id })
end
# Counts uncategorized, non-transfer entries in the given scope.
# Used by the Quick Categorize Wizard to show the remaining count.
# @param entries [ActiveRecord::Relation] pre-scoped entries (caller controls authorization)
def self.uncategorized_count(entries)
entries
.joins(:account)
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(accounts: { status: %w[draft active] })
.where(transactions: { category_id: nil })
.where.not(transactions: { kind: Transaction::TRANSFER_KINDS })
.where(entries: { excluded: false })
.count
end
# Returns uncategorized, non-transfer entries whose name matches the given filter string.
# Used by the Quick Categorize Wizard to preview which transactions a rule would affect.
# @param entries [ActiveRecord::Relation] pre-scoped entries (caller controls authorization)
def self.uncategorized_matching(entries, filter, transaction_type = nil)
sanitized = sanitize_sql_like(filter.gsub(/\s+/, " ").strip)
scope = entries
.joins(:account)
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(accounts: { status: %w[draft active] })
.where(transactions: { category_id: nil })
.where.not(transactions: { kind: Transaction::TRANSFER_KINDS })
.where(entries: { excluded: false })
.where("BTRIM(REGEXP_REPLACE(entries.name, '[[:space:]]+', ' ', 'g')) ILIKE ?", "%#{sanitized}%")
scope = case transaction_type
when "income" then scope.where("entries.amount < 0")
when "expense" then scope.where("entries.amount >= 0")
else scope
end
scope.includes(entryable: :merchant).order(entries: { date: :desc }).to_a
end
# Auto-exclude stale pending transactions for an account
# Called during sync to clean up pending transactions that never posted
# @param account [Account] The account to clean up

View File

@@ -144,6 +144,17 @@ class Family < ApplicationRecord
AutoMerchantDetector.new(self, transaction_ids: transaction_ids).auto_detect
end
def uncategorized_transaction_count
Transaction
.joins("INNER JOIN entries ON entries.entryable_id = transactions.id AND entries.entryable_type = 'Transaction'")
.joins("INNER JOIN accounts ON accounts.id = entries.account_id")
.where(accounts: { family_id: id, status: %w[draft active] })
.where(transactions: { category_id: nil })
.where.not(transactions: { kind: Transaction::TRANSFER_KINDS })
.where(entries: { excluded: false })
.count
end
def balance_sheet(user: Current.user)
BalanceSheet.new(self, user: user)
end

View File

@@ -40,6 +40,19 @@ class Rule < ApplicationRecord
matching_resources_scope.count
end
# Creates a categorization rule for the Quick Categorize Wizard.
# Returns the saved rule, or nil if a duplicate or invalid rule already exists.
def self.create_from_grouping(family, grouping_key, category, transaction_type: nil)
rule = family.rules.build(name: grouping_key, resource_type: "transaction", active: true)
rule.conditions.build(condition_type: "transaction_name", operator: "like", value: grouping_key)
rule.conditions.build(condition_type: "transaction_type", operator: "=", value: transaction_type) if transaction_type.present?
rule.actions.build(action_type: "set_transaction_category", value: category.id.to_s)
rule.save!
rule
rescue ActiveRecord::RecordInvalid
nil
end
# Calculates total unique resources affected across multiple rules
# This handles overlapping rules by deduplicating transaction IDs
def self.total_affected_resource_count(rules)

View File

@@ -0,0 +1,17 @@
class Transaction::Grouper
Group = Data.define(:grouping_key, :display_name, :entries, :merchant, :transaction_type)
# Returns the active grouping strategy class.
# Change this method to swap algorithms without touching the wizard.
def self.strategy
Transaction::Grouper::ByMerchantOrName
end
# @param entries [ActiveRecord::Relation] pre-scoped entries to group (caller controls authorization)
# @param limit [Integer] max number of groups to return
# @param offset [Integer] number of groups to skip (for pagination)
# @return [Array<Group>]
def self.call(entries, limit: 20, offset: 0)
raise NotImplementedError, "#{name} must implement .call"
end
end

View File

@@ -0,0 +1,53 @@
class Transaction::Grouper::ByMerchantOrName < Transaction::Grouper
def self.call(entries, limit: 20, offset: 0)
new(entries).call(limit: limit, offset: offset)
end
def initialize(entries)
@entries = entries
end
def call(limit: 20, offset: 0)
uncategorized_entries
.group_by { |entry| grouping_key_for(entry) }
.map { |key, entries| build_group(key, entries) }
.sort_by { |g| [ -g.entries.size, g.display_name ] }
.drop([ offset, 0 ].max)
.first(limit)
end
private
attr_reader :entries
def uncategorized_entries
entries
.joins(:account)
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(accounts: { status: %w[draft active] })
.where(transactions: { category_id: nil })
.where.not(transactions: { kind: Transaction::TRANSFER_KINDS })
.where(entries: { excluded: false })
.includes(entryable: :merchant)
.order(entries: { date: :desc })
end
def grouping_key_for(entry)
name = entry.entryable.merchant&.name.presence || entry.name
type = entry.amount.negative? ? "income" : "expense"
[ name, type ]
end
def build_group(key, entries)
name, type = key
merchant = entries.find { |e| e.entryable.merchant.present? }&.entryable&.merchant
Transaction::Grouper::Group.new(
grouping_key: name,
display_name: name,
entries: entries,
merchant: merchant,
transaction_type: type
)
end
end

View File

@@ -0,0 +1,20 @@
<%= turbo_frame_tag "categorize_entry_#{entry.id}" do %>
<input type="hidden" name="all_entry_ids[]" value="<%= entry.id %>">
<div class="grid grid-cols-[1rem_1fr_5rem_6rem_6.5rem] gap-3 items-center px-5 py-3 bg-container hover:bg-container-hover">
<input type="checkbox" name="entry_ids[]" value="<%= entry.id %>"
checked class="checkbox checkbox--light shrink-0"
aria-label="<%= t("transactions.categorizes.entry_row.include_checkbox", name: entry.name) %>"
data-action="change->categorize#uncheckRule">
<span class="min-w-0 font-medium text-primary truncate"><%= entry.name %></span>
<span class="text-secondary whitespace-nowrap"><%= l(entry.date, format: :short) %></span>
<span class="font-medium text-right whitespace-nowrap <%= entry.amount.negative? ? "text-green-600" : "text-primary" %>">
<%= format_money(entry.amount_money.abs) %>
</span>
<%= 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" } %>
</div>
<% end %>

View File

@@ -0,0 +1,7 @@
<%= turbo_frame_tag "categorize_group_summary" do %>
<p class="text-sm text-secondary">
<%= t("transactions.categorizes.show.transaction_count", count: entries.size) %>
&middot;
<%= format_money(entries.sum { |e| e.amount_money.abs }) %>
</p>
<% end %>

View File

@@ -0,0 +1,10 @@
<%= turbo_frame_tag "categorize_group_title" do %>
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-primary"><%= display_name %></h2>
<% if transaction_type == "income" %>
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-green-100 text-green-700"><%= t("transactions.categorizes.show.type_income") %></span>
<% elsif transaction_type == "expense" %>
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-surface text-secondary border border-primary"><%= t("transactions.categorizes.show.type_expense") %></span>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,5 @@
<%= turbo_frame_tag "categorize_remaining" do %>
<span class="text-sm text-secondary">
<%= t("transactions.categorizes.show.remaining", count: total_uncategorized) %>
</span>
<% end %>

View File

@@ -0,0 +1,20 @@
<%= turbo_frame_tag "categorize_transaction_list" do %>
<div class="bg-container-inset rounded-xl p-1 space-y-1">
<%# Header — same grid template and padding as each row %>
<div class="grid grid-cols-[1rem_1fr_5rem_6rem_6.5rem] gap-3 px-5 py-2 text-xs font-medium text-secondary uppercase tracking-wide">
<div></div>
<p><%= t("transactions.categorizes.show.col_transaction") %></p>
<p><%= t("transactions.categorizes.show.col_date") %></p>
<p class="text-right"><%= t("transactions.categorizes.show.col_amount") %></p>
<p><%= t("transactions.categorizes.show.col_category") %></p>
</div>
<%# Rows %>
<div class="shadow-border-xs rounded-lg overflow-hidden">
<div class="max-h-[32rem] overflow-y-auto divide-y divide-primary text-sm">
<% entries.each do |entry| %>
<%= render partial: "transactions/categorizes/entry_row", locals: { entry: entry, categories: categories } %>
<% end %>
</div>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,145 @@
<%# Wizard step: categorize one group of uncategorized transactions %>
<div class="w-full space-y-6 pb-12"
data-controller="categorize"
data-categorize-assign-entry-url-value="<%= assign_entry_transactions_categorize_path %>"
data-categorize-preview-rule-url-value="<%= preview_rule_transactions_categorize_path %>"
data-categorize-position-value="<%= @position %>"
data-categorize-transaction-type-value="<%= @group.transaction_type %>">
<%# Top bar: remaining count + skip %>
<div class="flex items-center justify-between">
<%= turbo_frame_tag "categorize_remaining" do %>
<span class="text-sm text-secondary">
<%= t(".remaining", count: @total_uncategorized) %>
</span>
<% 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 %>
</div>
<%# Group identity — above columns %>
<div>
<div class="flex items-center gap-3 mb-1">
<%= render partial: "transactions/categorizes/group_title",
locals: { display_name: @group.display_name, color: @group.merchant&.color || "#737373", transaction_type: @group.transaction_type } %>
</div>
<%= turbo_frame_tag "categorize_group_summary" do %>
<p class="text-sm text-secondary">
<%= t(".transaction_count", count: @group.entries.size) %>
&middot;
<%= format_money(@group.entries.sum { |e| e.amount_money.abs }) %>
</p>
<% end %>
</div>
<%# 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 %>
<div class="grid grid-cols-[3fr_2fr] gap-4 items-start w-full">
<%# Left column (60%): rule creation + transaction list %>
<div class="space-y-4">
<%# Rule creation %>
<div class="bg-container shadow-border-xs rounded-xl p-4 space-y-3">
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" name="create_rule" value="1"
<%= "checked" if @group.entries.size > 1 %>
class="checkbox checkbox--light"
data-categorize-target="createRuleCheckbox"
data-action="change->categorize#toggleRuleDetails">
<span class="text-sm font-medium text-primary"><%= t(".create_rule_label") %></span>
</label>
<div data-categorize-target="ruleDetails" class="<%= "opacity-40" unless @group.entries.size > 1 %>">
<input type="hidden" name="grouping_key"
value="<%= @group.grouping_key %>"
data-categorize-target="groupingKeyHidden">
<p class="text-sm text-secondary leading-relaxed">
<%= t(".rule_description_prefix", type: t(".type_#{@group.transaction_type}").downcase) %>
<span class="inline-flex items-center gap-1 align-middle">
<span class="font-medium text-primary" data-categorize-target="filterDisplay">"<%= @group.grouping_key %>"</span>
<button type="button"
class="text-secondary hover:text-primary transition-colors"
data-action="click->categorize#startFilterEdit"
data-categorize-target="filterEditTrigger">
<%= icon("pencil", size: "sm") %>
</button>
<span class="hidden items-center gap-1" data-categorize-target="filterEditArea">
<input type="text" value="<%= @group.grouping_key %>"
autocomplete="off"
class="text-sm border border-primary rounded-lg px-2 py-0.5 bg-container focus:outline-none focus:ring-1 focus:ring-offset-0 w-36 shadow-md"
data-categorize-target="filterInput"
data-action="keydown.enter->categorize#confirmFilterEdit keydown.escape->categorize#cancelFilterEdit">
<button type="button"
class="flex items-center justify-center w-6 h-6 rounded-md bg-primary text-inverted hover:opacity-80 transition-opacity shrink-0"
data-action="click->categorize#confirmFilterEdit">
<%= icon("check", size: "sm") %>
</button>
</span>
</span>
<%= t(".rule_description_suffix") %>
</p>
</div>
</div>
<%# Transaction list %>
<div class="bg-container rounded-xl shadow-border-xs p-4 space-y-3">
<p class="text-xs text-secondary"><%= t(".transactions_hint") %></p>
<%= render partial: "transactions/categorizes/transaction_list",
locals: { entries: @group.entries, categories: @categories } %>
</div>
</div>
<%# Right column (40%): category picker %>
<div>
<div class="bg-container shadow-border-xs rounded-xl p-4 space-y-3">
<p class="text-sm font-medium text-secondary"><%= t(".assign_category") %></p>
<div data-controller="list-filter" class="space-y-3">
<%# Search field %>
<div class="relative flex items-center bg-container border border-primary rounded-lg">
<%= icon("search", class: "absolute left-3 text-secondary") %>
<input type="text"
placeholder="<%= t(".filter_placeholder") %>"
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">
</div>
<%# Category pills — submit the form directly %>
<div data-list-filter-target="list" data-categorize-target="list"
data-action="click->categorize#clearFilter"
class="flex flex-wrap gap-2">
<p class="hidden text-sm text-secondary w-full py-2" data-list-filter-target="emptyMessage">
<%= t(".no_categories") %>
</p>
<% @categories.each do |category| %>
<button type="submit"
name="category_id"
value="<%= category.id %>"
class="filterable-item flex items-center gap-1 text-sm font-medium rounded-full px-2.5 py-1 border cursor-pointer hover:opacity-80 transition-opacity focus:outline-none focus:ring-2 focus:ring-offset-1"
style="background-color: color-mix(in oklab, <%= category.color %> 10%, transparent);
border-color: color-mix(in oklab, <%= category.color %> 20%, transparent);
color: <%= category.color %>;"
data-filter-name="<%= category.name %>">
<% if category.lucide_icon.present? %>
<%= icon(category.lucide_icon, size: "sm", color: "current") %>
<% end %>
<%= category.name %>
</button>
<% end %>
</div>
</div>
</div>
</div>
</div>
<% end %>
</div>

View File

@@ -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 %>
<div class="hidden md:flex">

View File

@@ -1,6 +1,8 @@
---
en:
breadcrumbs:
categorize: Categorize
exports: Exports
home: Home
imports: Imports
transactions: Transactions

View File

@@ -124,6 +124,42 @@ en:
transaction: transaction
transactions: transactions
import: Import
categorize_button:
one: "Categorize (1)"
other: "Categorize (%{count})"
categorizes:
show:
exit: "Exit"
skip: "Skip"
remaining:
one: "1 uncategorized transaction remaining"
other: "%{count} uncategorized transactions remaining"
transaction_count:
one: "1 transaction"
other: "%{count} transactions"
transactions_hint: "Uncheck to exclude a transaction, or assign it a different category directly in its row."
assign_category: "Assign a category"
assign_category_prompt: "→ assign"
filter_placeholder: "Search categories..."
col_transaction: "Transaction"
col_date: "Date"
col_amount: "Amount"
col_category: "Category"
type_income: "Income"
type_expense: "Expense"
create_rule_label: "Create Categorization Rule"
rule_description_prefix: "Future %{type} transactions with name containing"
rule_description_suffix: "should also get this category."
no_categories: "No matching categories"
all_done: "All transactions are categorized"
create:
categorized:
one: "1 transaction categorized"
other: "%{count} transactions categorized"
rule_creation_failed: "Transactions categorized, but the rule could not be created (it may already exist)."
entry_row:
include_checkbox: "Include %{name}"
assign_category_select: "Assign category for %{name}"
list:
drag_drop_title: Drop CSV to import
drag_drop_subtitle: Upload transactions directly

View File

@@ -274,6 +274,10 @@ Rails.application.routes.draw do
namespace :transactions do
resource :bulk_deletion, only: :create
resource :bulk_update, only: %i[new create]
resource :categorize, only: %i[show create] do
patch :assign_entry, on: :collection
get :preview_rule, on: :collection
end
end
resources :transactions, only: %i[index new create show update destroy] do

8
db/schema.rb generated
View File

@@ -63,8 +63,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
t.string "institution_name"
t.string "institution_domain"
t.text "notes"
t.jsonb "holdings_snapshot_data"
t.datetime "holdings_snapshot_at"
t.uuid "owner_id"
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
@@ -1184,7 +1182,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
t.index ["country_code"], name: "index_securities_on_country_code"
t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic"
t.index ["kind"], name: "index_securities_on_kind"
t.check_constraint "kind = ANY (ARRAY['standard'::text, 'cash'::text])", name: "chk_securities_kind"
t.check_constraint "kind::text = ANY (ARRAY['standard'::character varying, 'cash'::character varying]::text[])", name: "chk_securities_kind"
end
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -1514,10 +1512,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do
t.string "kind", default: "reconciliation", null: false
end
# Could not dump table "vector_store_chunks" because of following StandardError
# Unknown type 'vector(768)' for column 'embedding'
create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false

View File

@@ -0,0 +1,268 @@
require "test_helper"
class Transactions::CategorizesControllerTest < ActionDispatch::IntegrationTest
include EntriesTestHelper
setup do
sign_in @user = users(:family_admin)
@family = @user.family
@account = accounts(:depository)
@category = categories(:food_and_drink)
# Clear entries for isolation
@family.accounts.each { |a| a.entries.delete_all }
end
# GET /transactions/categorize
test "show redirects with notice when nothing to categorize" do
get transactions_categorize_url
assert_redirected_to transactions_url
assert_match "categorized", flash[:notice]
end
test "show renders wizard when uncategorized transactions exist" do
create_transaction(account: @account, name: "Starbucks")
get transactions_categorize_url
assert_response :success
end
test "show renders the first group at position 0" do
2.times { create_transaction(account: @account, name: "Netflix") }
3.times { create_transaction(account: @account, name: "Starbucks") }
get transactions_categorize_url(position: 0)
assert_response :success
assert_select "h2", text: "Starbucks"
end
test "show at position 1 skips first group" do
3.times { create_transaction(account: @account, name: "Starbucks") }
2.times { create_transaction(account: @account, name: "Netflix") }
get transactions_categorize_url(position: 1)
assert_response :success
assert_select "h2", text: "Netflix"
end
test "show redirects when position exceeds available groups" do
create_transaction(account: @account, name: "Starbucks")
get transactions_categorize_url(position: 99)
assert_redirected_to transactions_url
end
test "requires authentication" do
sign_out
get transactions_categorize_url
assert_redirected_to new_session_url
end
# Account sharing authorization
test "show only groups entries from accounts accessible to the user" do
accessible_account = accounts(:depository) # shared with family_member (full_control)
inaccessible_account = accounts(:investment) # not shared with family_member
create_transaction(account: accessible_account, name: "Starbucks")
create_transaction(account: inaccessible_account, name: "Starbucks")
sign_in users(:family_member)
get transactions_categorize_url(position: 0)
assert_response :success
# Only 1 entry should appear in the group — the inaccessible account's entry is hidden
assert_select "input[name='entry_ids[]']", count: 1
end
test "create does not categorize entries from inaccessible accounts" do
inaccessible_account = accounts(:investment) # not shared with family_member
entry = create_transaction(account: inaccessible_account, name: "Starbucks")
sign_in users(:family_member)
post transactions_categorize_url,
params: {
position: 0,
grouping_key: "Starbucks",
entry_ids: [ entry.id ],
all_entry_ids: [ entry.id ],
category_id: @category.id
},
headers: { "Accept" => "text/vnd.turbo-stream.html" }
assert_nil entry.transaction.reload.category
end
test "assign_entry does not categorize an entry from an inaccessible account" do
inaccessible_account = accounts(:investment) # not shared with family_member
entry = create_transaction(account: inaccessible_account, name: "Starbucks")
sign_in users(:family_member)
patch assign_entry_transactions_categorize_url, params: {
entry_id: entry.id,
category_id: @category.id,
position: 0,
all_entry_ids: [ entry.id ]
}
assert_response :not_found
assert_nil entry.transaction.reload.category
end
# GET /transactions/categorize/preview_rule
test "preview_rule returns matching entries for a filter" do
create_transaction(account: @account, name: "Amazon Prime")
create_transaction(account: @account, name: "Amazon Music")
create_transaction(account: @account, name: "Starbucks")
get preview_rule_transactions_categorize_url(filter: "Amazon"),
headers: { "Accept" => "text/vnd.turbo-stream.html" }
assert_response :success
assert_includes response.body, "Amazon Prime"
assert_includes response.body, "Amazon Music"
assert_not_includes response.body, "Starbucks"
end
test "preview_rule returns empty list for blank filter" do
create_transaction(account: @account, name: "Amazon")
get preview_rule_transactions_categorize_url(filter: ""),
headers: { "Accept" => "text/vnd.turbo-stream.html" }
assert_response :success
assert_not_includes response.body, "Amazon"
end
test "preview_rule requires authentication" do
sign_out
get preview_rule_transactions_categorize_url(filter: "Amazon")
assert_redirected_to new_session_url
end
private
def sign_out
@user.sessions.each { |s| delete session_path(s) }
end
# POST /transactions/categorize
test "create categorizes selected entries and returns redirect stream when all assigned" do
entry = create_transaction(account: @account, name: "Starbucks")
post transactions_categorize_url,
params: {
position: 0,
grouping_key: "Starbucks",
entry_ids: [ entry.id ],
all_entry_ids: [ entry.id ],
category_id: @category.id
},
headers: { "Accept" => "text/vnd.turbo-stream.html" }
assert_response :success
assert_equal @category, entry.transaction.reload.category
assert_includes response.body, "action=\"redirect\""
end
test "create removes assigned rows and replaces remaining when partial assignment" do
entry1 = create_transaction(account: @account, name: "Starbucks")
entry2 = create_transaction(account: @account, name: "Starbucks")
post transactions_categorize_url,
params: {
position: 0,
grouping_key: "Starbucks",
entry_ids: [ entry1.id ],
all_entry_ids: [ entry1.id, entry2.id ],
category_id: @category.id
},
headers: { "Accept" => "text/vnd.turbo-stream.html" }
assert_response :success
assert_equal @category, entry1.transaction.reload.category
assert_nil entry2.transaction.reload.category
# Remove stream for categorized entry
assert_includes response.body, "categorize_entry_#{entry1.id}"
# Replace stream for remaining entry (re-checked)
assert_includes response.body, "categorize_entry_#{entry2.id}"
# No redirect stream — still in the group
assert_not_includes response.body, "action=\"redirect\""
end
test "create with create_rule param creates rule with name and type conditions" do
entry = create_transaction(account: @account, name: "Netflix", amount: 15)
assert_difference "@family.rules.count", 1 do
post transactions_categorize_url,
params: {
position: 0,
grouping_key: "Netflix",
transaction_type: "expense",
entry_ids: [ entry.id ],
all_entry_ids: [ entry.id ],
category_id: @category.id,
create_rule: "1"
},
headers: { "Accept" => "text/vnd.turbo-stream.html" }
end
rule = @family.rules.find_by(name: "Netflix")
assert_not_nil rule
assert rule.active
assert rule.conditions.any? { |c| c.condition_type == "transaction_name" && c.value == "Netflix" }
assert rule.conditions.any? { |c| c.condition_type == "transaction_type" && c.value == "expense" }
end
test "create falls back to html redirect without turbo stream header" do
entry = create_transaction(account: @account, name: "Starbucks")
post transactions_categorize_url, params: {
position: 0,
grouping_key: "Starbucks",
entry_ids: [ entry.id ],
all_entry_ids: [ entry.id ],
category_id: @category.id
}
assert_redirected_to transactions_categorize_url(position: 0)
assert flash[:notice].present?
end
# PATCH /transactions/categorize/assign_entry
test "assign_entry categorizes single entry and returns remove stream" do
entry = create_transaction(account: @account, name: "Starbucks")
other = create_transaction(account: @account, name: "Starbucks")
patch assign_entry_transactions_categorize_url, params: {
entry_id: entry.id,
category_id: @category.id,
position: 0,
all_entry_ids: [ entry.id, other.id ]
}
assert_response :success
assert_equal @category, entry.transaction.reload.category
assert_includes response.body, "categorize_entry_#{entry.id}"
assert_not_includes response.body, "action=\"redirect\""
end
test "assign_entry returns redirect stream when last entry in group" do
entry = create_transaction(account: @account, name: "Starbucks")
patch assign_entry_transactions_categorize_url, params: {
entry_id: entry.id,
category_id: @category.id,
position: 0,
all_entry_ids: [ entry.id ]
}
assert_response :success
assert_includes response.body, "action=\"redirect\""
end
end

View File

@@ -0,0 +1,140 @@
require "test_helper"
class Transaction::Grouper::ByMerchantOrNameTest < ActiveSupport::TestCase
include EntriesTestHelper
setup do
@family = families(:dylan_family)
@account = accounts(:depository)
# Clear existing entries for isolation
@family.accounts.each { |a| a.entries.delete_all }
end
test "groups uncategorized transactions by merchant name when merchant present" do
merchant = merchants(:netflix)
create_transaction(account: @account, name: "NETFLIX.COM", merchant: merchant)
create_transaction(account: @account, name: "Netflix Monthly", merchant: merchant)
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries)
assert_equal 1, groups.size
assert_equal "Netflix", groups.first.grouping_key
assert_equal 2, groups.first.entries.size
end
test "falls back to entry name when no merchant" do
create_transaction(account: @account, name: "AMZN MKTP US")
create_transaction(account: @account, name: "AMZN MKTP US")
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries)
assert_equal 1, groups.size
assert_equal "AMZN MKTP US", groups.first.grouping_key
assert_equal 2, groups.first.entries.size
end
test "creates separate groups for different names" do
create_transaction(account: @account, name: "Starbucks")
create_transaction(account: @account, name: "Netflix")
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries)
assert_equal 2, groups.size
end
test "creates separate groups for same name with different types" do
create_transaction(account: @account, name: "Refund", amount: 50) # expense
create_transaction(account: @account, name: "Refund", amount: -50) # income
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries)
assert_equal 2, groups.size
types = groups.map(&:transaction_type).sort
assert_equal %w[expense income], types
end
test "sets transaction_type to income for negative amounts" do
create_transaction(account: @account, name: "Paycheck", amount: -1000)
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries)
assert_equal "income", groups.first.transaction_type
end
test "sets transaction_type to expense for positive amounts" do
create_transaction(account: @account, name: "Coffee", amount: 5)
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries)
assert_equal "expense", groups.first.transaction_type
end
test "excludes transfer kinds" do
create_transaction(account: @account, name: "CC Payment", kind: "cc_payment")
create_transaction(account: @account, name: "Funds Move", kind: "funds_movement")
create_transaction(account: @account, name: "Regular")
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries)
assert_equal 1, groups.size
assert_equal "Regular", groups.first.grouping_key
end
test "excludes already-categorized transactions" do
create_transaction(account: @account, name: "Categorized", category: categories(:food_and_drink))
create_transaction(account: @account, name: "Uncategorized")
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries)
assert_equal 1, groups.size
assert_equal "Uncategorized", groups.first.grouping_key
end
test "excludes excluded entries" do
entry = create_transaction(account: @account, name: "Excluded")
entry.update!(excluded: true)
create_transaction(account: @account, name: "Visible")
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries)
assert_equal 1, groups.size
assert_equal "Visible", groups.first.grouping_key
end
test "returns empty array when all transactions are categorized" do
create_transaction(account: @account, name: "Coffee", category: categories(:food_and_drink))
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries)
assert_empty groups
end
test "sorts groups by count descending then name ascending" do
3.times { create_transaction(account: @account, name: "Starbucks") }
create_transaction(account: @account, name: "Netflix")
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries)
assert_equal "Starbucks", groups.first.grouping_key
assert_equal "Netflix", groups.last.grouping_key
end
test "respects limit and offset" do
create_transaction(account: @account, name: "A")
create_transaction(account: @account, name: "B")
create_transaction(account: @account, name: "C")
# limit
groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries, limit: 2)
assert_equal 2, groups.size
# all groups
all_groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries, limit: 10)
assert_equal 3, all_groups.size
# offset skips leading groups
offset_groups = Transaction::Grouper::ByMerchantOrName.call(@family.entries, limit: 10, offset: 1)
assert_equal 2, offset_groups.size
assert_not_includes offset_groups.map(&:grouping_key), all_groups.first.grouping_key
end
end