feat: Add subcategory breakdown to Cash Flow Sankey and Reports (#639)

* feat: Add subcategory breakdown to Cash Flow and Reports

Implements Discussion #546 - adds hierarchical category/subcategory
visualization to both the Sankey chart and Reports breakdown tables.

Sankey chart changes:
- Income: subcategory → parent category → Cash Flow
- Expense: Cash Flow → parent category → subcategory
- Extracted process_category_totals helper to DRY up income/expense logic

Reports breakdown changes:
- Subcategories display nested under parent categories
- Smaller dots and indented rows for visual hierarchy
- Extracted _breakdown_table partial to eliminate duplication

* fix: Dynamic node padding for Sankey chart with many nodes

- Add dynamic nodePadding calculation to prevent padding from dominating
  chart height when there are many subcategory nodes
- Extract magic numbers to static constants for configuration
- Decompose monolithic #draw() into focused methods
- Consolidate duplicate tooltip/currency formatting code
- Modernize syntax with spread operators and optional chaining

* fix: Hide overlapping Sankey labels, show on hover

- Add label overlap detection by grouping nodes by column depth
- Hide labels that would overlap with adjacent nodes
- Show hidden labels on hover (node rectangle or connected links)
- Add hover events to node rectangles (not just text)

* fix: Use deterministic fallback colors for categories

- Replace Category::COLORS.sample with Category::UNCATEGORIZED_COLOR
  for income categories in Sankey chart (was producing different colors
  on each page load)
- Add nil color fallback in reports_controller for parent and root
  categories

Addresses CodeRabbit review feedback.

* fix: Expand CSS variable map for d3 color manipulation

Add hex mappings for commonly used CSS variables so d3 can manipulate
opacity for gradients and hover effects:
- var(--color-destructive) -> #EC2222
- var(--color-gray-400) -> #9E9E9E
- var(--color-gray-500) -> #737373

* test: Add tests for subcategory breakdown in dashboard and reports

- Test dashboard renders Sankey chart with parent/subcategory transactions
- Test reports groups transactions by parent and subcategories
- Test reports handles categories with nil colors
- Use EntriesTestHelper#create_transaction for cleaner test setup

* Fix lint: use Number.NEGATIVE_INFINITY

* Remove obsolete nil color test

Category model now validates color presence, so nil color categories
cannot exist. The fallback handling in reports_controller is still in
place but the scenario is unreachable.

* Update reports_controller.rb

* FIX trade category

---------

Co-authored-by: sokie <sokysrm@gmail.com>
This commit is contained in:
David Gil
2026-01-20 00:01:55 +01:00
committed by GitHub
parent d4be209ce5
commit 3d91e60a8a
8 changed files with 557 additions and 367 deletions

View File

@@ -144,37 +144,29 @@ class PagesController < ApplicationController
# Central Cash Flow node
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income, 100.0, "var(--color-success)")
# Income side (top-level categories only)
income_totals.category_totals.each do |ct|
next if ct.category.parent_id.present?
# Process income categories (flow: subcategory -> parent -> cash_flow)
process_category_totals(
category_totals: income_totals.category_totals,
total: total_income,
prefix: "income",
default_color: Category::UNCATEGORIZED_COLOR,
add_node: add_node,
links: links,
cash_flow_idx: cash_flow_idx,
flow_direction: :inbound
)
val = ct.total.to_f.round(2)
next if val.zero?
percentage = total_income.zero? ? 0 : (val / total_income * 100).round(1)
color = ct.category.color.presence || Category::COLORS.sample
# Use name as fallback key for synthetic categories (no id)
node_key = "income_#{ct.category.id || ct.category.name}"
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
end
# Expense side (top-level categories only)
expense_totals.category_totals.each do |ct|
next if ct.category.parent_id.present?
val = ct.total.to_f.round(2)
next if val.zero?
percentage = total_expense.zero? ? 0 : (val / total_expense * 100).round(1)
color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
# Use name as fallback key for synthetic categories (no id)
node_key = "expense_#{ct.category.id || ct.category.name}"
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
end
# Process expense categories (flow: cash_flow -> parent -> subcategory)
process_category_totals(
category_totals: expense_totals.category_totals,
total: total_expense,
prefix: "expense",
default_color: Category::UNCATEGORIZED_COLOR,
add_node: add_node,
links: links,
cash_flow_idx: cash_flow_idx,
flow_direction: :outbound
)
# Surplus/Deficit
net = (total_income - total_expense).round(2)
@@ -209,4 +201,63 @@ class PagesController < ApplicationController
{ categories: categories, total: total.to_f.round(2), currency: expense_totals.currency, currency_symbol: currency_symbol }
end
# Processes category totals for sankey diagram, handling parent/subcategory relationships.
# flow_direction: :inbound (subcategory -> parent -> cash_flow) for income
# :outbound (cash_flow -> parent -> subcategory) for expenses
def process_category_totals(category_totals:, total:, prefix:, default_color:, add_node:, links:, cash_flow_idx:, flow_direction:)
# Build lookup of subcategories by parent_id
subcategories_by_parent = category_totals
.select { |ct| ct.category.parent_id.present? && ct.total.to_f > 0 }
.group_by { |ct| ct.category.parent_id }
category_totals.each do |ct|
next if ct.category.parent_id.present? # Skip subcategories in first pass
val = ct.total.to_f.round(2)
next if val.zero?
percentage = total.zero? ? 0 : (val / total * 100).round(1)
color = ct.category.color.presence || default_color
node_key = "#{prefix}_#{ct.category.id || ct.category.name}"
subs = subcategories_by_parent[ct.category.id] || []
if subs.any?
parent_idx = add_node.call(node_key, ct.category.name, val, percentage, color)
# Link parent to/from cash flow based on direction
if flow_direction == :inbound
links << { source: parent_idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
else
links << { source: cash_flow_idx, target: parent_idx, value: val, color: color, percentage: percentage }
end
# Add subcategory nodes
subs.each do |sub_ct|
sub_val = sub_ct.total.to_f.round(2)
sub_pct = val.zero? ? 0 : (sub_val / val * 100).round(1)
sub_color = sub_ct.category.color.presence || color
sub_key = "#{prefix}_sub_#{sub_ct.category.id}"
sub_idx = add_node.call(sub_key, sub_ct.category.name, sub_val, sub_pct, sub_color)
# Link subcategory to/from parent based on direction
if flow_direction == :inbound
links << { source: sub_idx, target: parent_idx, value: sub_val, color: sub_color, percentage: sub_pct }
else
links << { source: parent_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct }
end
end
else
# No subcategories, link directly to/from cash flow
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
if flow_direction == :inbound
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
else
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
end
end
end
end
end

View File

@@ -351,45 +351,86 @@ class ReportsController < ApplicationController
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Transaction", excluded: false, date: @period.date_range })
.where.not(kind: [ "funds_movement", "one_time", "cc_payment" ])
.includes(entry: :account, category: [])
.includes(entry: :account, category: :parent)
# Apply filters
transactions = apply_transaction_filters(transactions)
# Get trades in the period (matching income_statement logic)
trades = Trade
.joins(:entry)
.joins(entry: :account)
.where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] })
.where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range })
.includes(entry: :account, category: :parent)
# Get sort parameters
sort_by = params[:sort_by] || "amount"
sort_direction = params[:sort_direction] || "desc"
# Group by category and type
# Group by category (tracking parent relationship) and type
# Structure: { [parent_category_id, type] => { parent_data, subcategories: { subcategory_id => data } } }
grouped_data = {}
family_currency = Current.family.currency
# Process transactions
transactions.each do |transaction|
entry = transaction.entry
is_expense = entry.amount > 0
type = is_expense ? "expense" : "income"
category_name = transaction.category&.name || "Uncategorized"
category_color = transaction.category&.color || Category::UNCATEGORIZED_COLOR
# Convert to family currency
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
key = [ category_name, type, category_color ]
grouped_data[key] ||= { total: 0, count: 0 }
grouped_data[key][:count] += 1
grouped_data[key][:total] += converted_amount
# Helper to initialize a category group hash
init_category_group = ->(id, name, color, type) do
{ category_id: id, category_name: name, category_color: color, type: type, total: 0, count: 0, subcategories: {} }
end
# Convert to array
result = grouped_data.map do |key, data|
{
category_name: key[0],
type: key[1],
category_color: key[2],
total: data[:total],
count: data[:count]
}
# Helper to initialize a subcategory hash
init_subcategory = ->(category) do
{ category_id: category.id, category_name: category.name, category_color: category.color, total: 0, count: 0 }
end
# Helper to process an entry (transaction or trade)
process_entry = ->(category, entry, is_trade) do
type = entry.amount > 0 ? "expense" : "income"
converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount
if category.nil?
# Uncategorized or Other Investments (for trades)
if is_trade
parent_key = [ :other_investments, type ]
grouped_data[parent_key] ||= init_category_group.call(:other_investments, Category.other_investments_name, Category::OTHER_INVESTMENTS_COLOR, type)
else
parent_key = [ :uncategorized, type ]
grouped_data[parent_key] ||= init_category_group.call(:uncategorized, Category.uncategorized_name, Category::UNCATEGORIZED_COLOR, type)
end
elsif category.parent_id.present?
# This is a subcategory - group under parent
parent = category.parent
parent_key = [ parent.id, type ]
grouped_data[parent_key] ||= init_category_group.call(parent.id, parent.name, parent.color || Category::UNCATEGORIZED_COLOR, type)
# Add to subcategory
grouped_data[parent_key][:subcategories][category.id] ||= init_subcategory.call(category)
grouped_data[parent_key][:subcategories][category.id][:count] += 1
grouped_data[parent_key][:subcategories][category.id][:total] += converted_amount
else
# This is a root category (no parent)
parent_key = [ category.id, type ]
grouped_data[parent_key] ||= init_category_group.call(category.id, category.name, category.color || Category::UNCATEGORIZED_COLOR, type)
end
grouped_data[parent_key][:count] += 1
grouped_data[parent_key][:total] += converted_amount
end
# Process transactions
transactions.each do |transaction|
process_entry.call(transaction.category, transaction.entry, false)
end
# Process trades
trades.each do |trade|
process_entry.call(trade.category, trade.entry, true)
end
# Convert to array and sort subcategories
result = grouped_data.values.map do |parent_data|
subcategories = parent_data[:subcategories].values.sort_by { |s| sort_direction == "asc" ? s[:total] : -s[:total] }
parent_data.merge(subcategories: subcategories)
end
# Sort by amount (total) with the specified direction