mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 22:34:47 +00:00
* 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>
264 lines
9.9 KiB
Ruby
264 lines
9.9 KiB
Ruby
class PagesController < ApplicationController
|
|
include Periodable
|
|
|
|
skip_authentication only: :redis_configuration_error
|
|
|
|
def dashboard
|
|
@balance_sheet = Current.family.balance_sheet
|
|
@investment_statement = Current.family.investment_statement
|
|
@accounts = Current.family.accounts.visible.with_attached_logo
|
|
|
|
family_currency = Current.family.currency
|
|
|
|
# Use IncomeStatement for all cashflow data (now includes categorized trades)
|
|
income_totals = Current.family.income_statement.income_totals(period: @period)
|
|
expense_totals = Current.family.income_statement.expense_totals(period: @period)
|
|
|
|
@cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)
|
|
@outflows_data = build_outflows_donut_data(expense_totals)
|
|
|
|
@dashboard_sections = build_dashboard_sections
|
|
|
|
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
|
|
end
|
|
|
|
def update_preferences
|
|
if Current.user.update_dashboard_preferences(preferences_params)
|
|
head :ok
|
|
else
|
|
head :unprocessable_entity
|
|
end
|
|
end
|
|
|
|
def changelog
|
|
@release_notes = github_provider.fetch_latest_release_notes
|
|
|
|
# Fallback if no release notes are available
|
|
if @release_notes.nil?
|
|
@release_notes = {
|
|
avatar: "https://github.com/we-promise.png",
|
|
username: "we-promise",
|
|
name: "Release notes unavailable",
|
|
published_at: Date.current,
|
|
body: "<p>Unable to fetch the latest release notes at this time. Please check back later or visit our <a href='https://github.com/we-promise/sure/releases' target='_blank'>GitHub releases page</a> directly.</p>"
|
|
}
|
|
end
|
|
|
|
render layout: "settings"
|
|
end
|
|
|
|
def feedback
|
|
render layout: "settings"
|
|
end
|
|
|
|
def redis_configuration_error
|
|
render layout: "blank"
|
|
end
|
|
|
|
private
|
|
def preferences_params
|
|
prefs = params.require(:preferences)
|
|
{}.tap do |permitted|
|
|
permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections]
|
|
permitted["section_order"] = prefs[:section_order] if prefs[:section_order]
|
|
end
|
|
end
|
|
|
|
def build_dashboard_sections
|
|
all_sections = [
|
|
{
|
|
key: "cashflow_sankey",
|
|
title: "pages.dashboard.cashflow_sankey.title",
|
|
partial: "pages/dashboard/cashflow_sankey",
|
|
locals: { sankey_data: @cashflow_sankey_data, period: @period },
|
|
visible: Current.family.accounts.any?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "outflows_donut",
|
|
title: "pages.dashboard.outflows_donut.title",
|
|
partial: "pages/dashboard/outflows_donut",
|
|
locals: { outflows_data: @outflows_data, period: @period },
|
|
visible: Current.family.accounts.any? && @outflows_data[:categories].present?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "investment_summary",
|
|
title: "pages.dashboard.investment_summary.title",
|
|
partial: "pages/dashboard/investment_summary",
|
|
locals: { investment_statement: @investment_statement, period: @period },
|
|
visible: Current.family.accounts.any? && @investment_statement.investment_accounts.any?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "net_worth_chart",
|
|
title: "pages.dashboard.net_worth_chart.title",
|
|
partial: "pages/dashboard/net_worth_chart",
|
|
locals: { balance_sheet: @balance_sheet, period: @period },
|
|
visible: Current.family.accounts.any?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "balance_sheet",
|
|
title: "pages.dashboard.balance_sheet.title",
|
|
partial: "pages/dashboard/balance_sheet",
|
|
locals: { balance_sheet: @balance_sheet },
|
|
visible: Current.family.accounts.any?,
|
|
collapsible: true
|
|
}
|
|
]
|
|
|
|
# Order sections according to user preference
|
|
section_order = Current.user.dashboard_section_order
|
|
ordered_sections = section_order.map do |key|
|
|
all_sections.find { |s| s[:key] == key }
|
|
end.compact
|
|
|
|
# Add any new sections that aren't in the saved order (future-proofing)
|
|
all_sections.each do |section|
|
|
ordered_sections << section unless ordered_sections.include?(section)
|
|
end
|
|
|
|
ordered_sections
|
|
end
|
|
|
|
def github_provider
|
|
Provider::Registry.get_provider(:github)
|
|
end
|
|
|
|
def build_cashflow_sankey_data(income_totals, expense_totals, currency)
|
|
nodes = []
|
|
links = []
|
|
node_indices = {}
|
|
|
|
add_node = ->(unique_key, display_name, value, percentage, color) {
|
|
node_indices[unique_key] ||= begin
|
|
nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }
|
|
nodes.size - 1
|
|
end
|
|
}
|
|
|
|
total_income = income_totals.total.to_f.round(2)
|
|
total_expense = expense_totals.total.to_f.round(2)
|
|
|
|
# Central Cash Flow node
|
|
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income, 100.0, "var(--color-success)")
|
|
|
|
# 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
|
|
)
|
|
|
|
# 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)
|
|
if net.positive?
|
|
percentage = total_income.zero? ? 0 : (net / total_income * 100).round(1)
|
|
idx = add_node.call("surplus_node", "Surplus", net, percentage, "var(--color-success)")
|
|
links << { source: cash_flow_idx, target: idx, value: net, color: "var(--color-success)", percentage: percentage }
|
|
end
|
|
|
|
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency).symbol }
|
|
end
|
|
|
|
def build_outflows_donut_data(expense_totals)
|
|
currency_symbol = Money::Currency.new(expense_totals.currency).symbol
|
|
total = expense_totals.total
|
|
|
|
categories = expense_totals.category_totals
|
|
.reject { |ct| ct.category.parent_id.present? || ct.total.zero? }
|
|
.sort_by { |ct| -ct.total }
|
|
.map do |ct|
|
|
{
|
|
id: ct.category.id,
|
|
name: ct.category.name,
|
|
amount: ct.total.to_f.round(2),
|
|
currency: ct.currency,
|
|
percentage: ct.weight.round(1),
|
|
color: ct.category.color.presence || Category::UNCATEGORIZED_COLOR,
|
|
icon: ct.category.lucide_icon,
|
|
clickable: !ct.category.other_investments?
|
|
}
|
|
end
|
|
|
|
{ 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
|