mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user