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

View File

@@ -1,6 +1,6 @@
import { Controller } from "@hotwired/stimulus";
import * as d3 from "d3";
import { sankey, sankeyLinkHorizontal } from "d3-sankey";
import { sankey } from "d3-sankey";
// Connects to data-controller="sankey-chart"
export default class extends Controller {
@@ -11,6 +11,22 @@ export default class extends Controller {
currencySymbol: { type: String, default: "$" }
};
// Visual constants
static HOVER_OPACITY = 0.4;
static HOVER_FILTER = "saturate(1.3) brightness(1.1)";
static EXTENT_MARGIN = 16;
static MIN_NODE_PADDING = 4;
static MAX_PADDING_RATIO = 0.4;
static CORNER_RADIUS = 8;
static DEFAULT_COLOR = "var(--color-gray-400)";
static CSS_VAR_MAP = {
"var(--color-success)": "#10A861",
"var(--color-destructive)": "#EC2222",
"var(--color-gray-400)": "#9E9E9E",
"var(--color-gray-500)": "#737373"
};
static MIN_LABEL_SPACING = 28; // Minimum vertical space needed for labels (2 lines)
connect() {
this.resizeObserver = new ResizeObserver(() => this.#draw());
this.resizeObserver.observe(this.element);
@@ -21,91 +37,63 @@ export default class extends Controller {
disconnect() {
this.resizeObserver?.disconnect();
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
this.tooltip?.remove();
this.tooltip = null;
}
#draw() {
const { nodes = [], links = [] } = this.dataValue || {};
if (!nodes.length || !links.length) return;
// Constants
const HOVER_OPACITY = 0.4;
const HOVER_FILTER = "saturate(1.3) brightness(1.1)";
// Hover utility functions
const applyHoverEffect = (targetLinks, allLinks, allNodes) => {
const targetLinksSet = new Set(targetLinks);
allLinks
.style("opacity", (linkData) => targetLinksSet.has(linkData) ? 1 : HOVER_OPACITY)
.style("filter", (linkData) => targetLinksSet.has(linkData) ? HOVER_FILTER : "none");
const connectedNodes = new Set();
targetLinks.forEach(link => {
connectedNodes.add(link.source);
connectedNodes.add(link.target);
});
allNodes.style("opacity", (nodeData) => connectedNodes.has(nodeData) ? 1 : HOVER_OPACITY);
};
const resetHoverEffect = (allLinks, allNodes) => {
allLinks.style("opacity", 1).style("filter", "none");
allNodes.style("opacity", 1);
};
// Clear previous SVG
d3.select(this.element).selectAll("svg").remove();
const width = this.element.clientWidth || 600;
const height = this.element.clientHeight || 400;
const svg = d3
.select(this.element)
const svg = d3.select(this.element)
.append("svg")
.attr("width", width)
.attr("height", height);
const effectivePadding = this.#calculateNodePadding(nodes.length, height);
const sankeyData = this.#generateSankeyData(nodes, links, width, height, effectivePadding);
this.#createGradients(svg, sankeyData.links);
const linkPaths = this.#drawLinks(svg, sankeyData.links);
const { nodeGroups, hiddenLabels } = this.#drawNodes(svg, sankeyData.nodes, width);
this.#attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels);
}
// Dynamic padding prevents padding from dominating when there are many nodes
#calculateNodePadding(nodeCount, height) {
const margin = this.constructor.EXTENT_MARGIN;
const availableHeight = height - (margin * 2);
const maxPaddingTotal = availableHeight * this.constructor.MAX_PADDING_RATIO;
const gaps = Math.max(nodeCount - 1, 1);
const dynamicPadding = Math.min(this.nodePaddingValue, Math.floor(maxPaddingTotal / gaps));
return Math.max(this.constructor.MIN_NODE_PADDING, dynamicPadding);
}
#generateSankeyData(nodes, links, width, height, nodePadding) {
const margin = this.constructor.EXTENT_MARGIN;
const sankeyGenerator = sankey()
.nodeWidth(this.nodeWidthValue)
.nodePadding(this.nodePaddingValue)
.extent([
[16, 16],
[width - 16, height - 16],
]);
.nodePadding(nodePadding)
.extent([[margin, margin], [width - margin, height - margin]]);
const sankeyData = sankeyGenerator({
nodes: nodes.map((d) => Object.assign({}, d)),
links: links.map((d) => Object.assign({}, d)),
return sankeyGenerator({
nodes: nodes.map(d => ({ ...d })),
links: links.map(d => ({ ...d })),
});
}
// Define gradients for links
#createGradients(svg, links) {
const defs = svg.append("defs");
sankeyData.links.forEach((link, i) => {
const gradientId = `link-gradient-${link.source.index}-${link.target.index}-${i}`;
const getStopColorWithOpacity = (nodeColorInput, opacity = 0.1) => {
let colorStr = nodeColorInput || "var(--color-gray-400)";
if (colorStr === "var(--color-success)") {
colorStr = "#10A861"; // Hex for --color-green-600
}
// Add other CSS var to hex mappings here if needed
if (colorStr.startsWith("var(--")) { // Unmapped CSS var, use as is (likely solid)
return colorStr;
}
const d3Color = d3.color(colorStr);
return d3Color ? d3Color.copy({ opacity: opacity }) : "var(--color-gray-400)";
};
const sourceStopColor = getStopColorWithOpacity(link.source.color);
const targetStopColor = getStopColorWithOpacity(link.target.color);
links.forEach((link, i) => {
const gradientId = this.#gradientId(link, i);
const gradient = defs.append("linearGradient")
.attr("id", gradientId)
.attr("gradientUnits", "userSpaceOnUse")
@@ -114,155 +102,242 @@ export default class extends Controller {
gradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", sourceStopColor);
.attr("stop-color", this.#colorWithOpacity(link.source.color));
gradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", targetStopColor);
.attr("stop-color", this.#colorWithOpacity(link.target.color));
});
}
// Draw links
const linksContainer = svg.append("g").attr("fill", "none");
const linkPaths = linksContainer
#gradientId(link, index) {
return `link-gradient-${link.source.index}-${link.target.index}-${index}`;
}
#colorWithOpacity(nodeColor, opacity = 0.1) {
const defaultColor = this.constructor.DEFAULT_COLOR;
let colorStr = nodeColor || defaultColor;
// Map CSS variables to hex values for d3 color manipulation
colorStr = this.constructor.CSS_VAR_MAP[colorStr] || colorStr;
// Unmapped CSS vars cannot be manipulated, return as-is
if (colorStr.startsWith("var(--")) return colorStr;
const d3Color = d3.color(colorStr);
return d3Color ? d3Color.copy({ opacity }) : defaultColor;
}
#drawLinks(svg, links) {
return svg.append("g")
.attr("fill", "none")
.selectAll("path")
.data(sankeyData.links)
.data(links)
.join("path")
.attr("class", "sankey-link")
.attr("d", (d) => {
const sourceX = d.source.x1;
const targetX = d.target.x0;
const path = d3.linkHorizontal()({
source: [sourceX, d.y0],
target: [targetX, d.y1]
});
return path;
})
.attr("stroke", (d, i) => `url(#link-gradient-${d.source.index}-${d.target.index}-${i})`)
.attr("stroke-width", (d) => Math.max(1, d.width))
.attr("d", d => d3.linkHorizontal()({
source: [d.source.x1, d.y0],
target: [d.target.x0, d.y1]
}))
.attr("stroke", (d, i) => `url(#${this.#gradientId(d, i)})`)
.attr("stroke-width", d => Math.max(1, d.width))
.style("transition", "opacity 0.3s ease");
}
// Draw nodes
const nodeGroups = svg
.append("g")
#drawNodes(svg, nodes, width) {
const nodeGroups = svg.append("g")
.selectAll("g")
.data(sankeyData.nodes)
.data(nodes)
.join("g")
.style("transition", "opacity 0.3s ease");
const cornerRadius = 8;
nodeGroups.append("path")
.attr("d", (d) => {
const x0 = d.x0;
const y0 = d.y0;
const x1 = d.x1;
const y1 = d.y1;
const h = y1 - y0;
// const w = x1 - x0; // Not directly used in path string, but good for context
.attr("d", d => this.#nodePath(d))
.attr("fill", d => d.color || this.constructor.DEFAULT_COLOR)
.attr("stroke", d => d.color ? "none" : "var(--color-gray-500)");
// Dynamic corner radius based on node height, maxed at 8
const effectiveCornerRadius = Math.max(0, Math.min(cornerRadius, h / 2));
const hiddenLabels = this.#addNodeLabels(nodeGroups, width, nodes);
const isSourceNode = d.sourceLinks && d.sourceLinks.length > 0 && (!d.targetLinks || d.targetLinks.length === 0);
const isTargetNode = d.targetLinks && d.targetLinks.length > 0 && (!d.sourceLinks || d.sourceLinks.length === 0);
return { nodeGroups, hiddenLabels };
}
if (isSourceNode) { // Round left corners, flat right for "Total Income"
if (h < effectiveCornerRadius * 2) {
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
}
return `M ${x0 + effectiveCornerRadius},${y0}
L ${x1},${y0}
L ${x1},${y1}
L ${x0 + effectiveCornerRadius},${y1}
Q ${x0},${y1} ${x0},${y1 - effectiveCornerRadius}
L ${x0},${y0 + effectiveCornerRadius}
Q ${x0},${y0} ${x0 + effectiveCornerRadius},${y0} Z`;
}
#nodePath(node) {
const { x0, y0, x1, y1 } = node;
const height = y1 - y0;
const radius = Math.max(0, Math.min(this.constructor.CORNER_RADIUS, height / 2));
if (isTargetNode) { // Flat left corners, round right for Categories/Surplus
if (h < effectiveCornerRadius * 2) {
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
}
return `M ${x0},${y0}
L ${x1 - effectiveCornerRadius},${y0}
Q ${x1},${y0} ${x1},${y0 + effectiveCornerRadius}
L ${x1},${y1 - effectiveCornerRadius}
Q ${x1},${y1} ${x1 - effectiveCornerRadius},${y1}
L ${x0},${y1} Z`;
}
const isSourceNode = node.sourceLinks?.length > 0 && !node.targetLinks?.length;
const isTargetNode = node.targetLinks?.length > 0 && !node.sourceLinks?.length;
// Fallback for intermediate nodes (e.g., "Cash Flow") - draw as a simple sharp-cornered rectangle
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
})
.attr("fill", (d) => d.color || "var(--color-gray-400)")
.attr("stroke", (d) => {
// If a node has an explicit color assigned (even if it's a gray variable),
// it gets no stroke. Only truly un-colored nodes (falling back to default fill)
// would get a stroke, but our current data structure assigns colors to all nodes.
if (d.color) {
return "none";
}
return "var(--color-gray-500)"; // Fallback, likely unused with current data
});
// Too small for rounded corners
if (height < radius * 2) {
return this.#rectPath(x0, y0, x1, y1);
}
// Add hover events to links after creating nodes
linkPaths
.on("mouseenter", (event, d) => {
applyHoverEffect([d], linkPaths, nodeGroups);
this.#showTooltip(event, d);
})
.on("mousemove", (event) => this.#updateTooltipPosition(event))
.on("mouseleave", () => {
resetHoverEffect(linkPaths, nodeGroups);
this.#hideTooltip();
});
if (isSourceNode) {
return this.#roundedLeftPath(x0, y0, x1, y1, radius);
}
const stimulusControllerInstance = this;
nodeGroups
.append("text")
.attr("x", (d) => (d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6))
.attr("y", (d) => (d.y1 + d.y0) / 2)
if (isTargetNode) {
return this.#roundedRightPath(x0, y0, x1, y1, radius);
}
return this.#rectPath(x0, y0, x1, y1);
}
#rectPath(x0, y0, x1, y1) {
return `M ${x0},${y0} L ${x1},${y0} L ${x1},${y1} L ${x0},${y1} Z`;
}
#roundedLeftPath(x0, y0, x1, y1, r) {
return `M ${x0 + r},${y0}
L ${x1},${y0}
L ${x1},${y1}
L ${x0 + r},${y1}
Q ${x0},${y1} ${x0},${y1 - r}
L ${x0},${y0 + r}
Q ${x0},${y0} ${x0 + r},${y0} Z`;
}
#roundedRightPath(x0, y0, x1, y1, r) {
return `M ${x0},${y0}
L ${x1 - r},${y0}
Q ${x1},${y0} ${x1},${y0 + r}
L ${x1},${y1 - r}
Q ${x1},${y1} ${x1 - r},${y1}
L ${x0},${y1} Z`;
}
#addNodeLabels(nodeGroups, width, nodes) {
const controller = this;
const hiddenLabels = this.#calculateHiddenLabels(nodes);
nodeGroups.append("text")
.attr("x", d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)
.attr("y", d => (d.y1 + d.y0) / 2)
.attr("dy", "-0.2em")
.attr("text-anchor", (d) => (d.x0 < width / 2 ? "start" : "end"))
.attr("text-anchor", d => d.x0 < width / 2 ? "start" : "end")
.attr("class", "text-xs font-medium text-primary fill-current select-none")
.style("cursor", "default")
.on("mouseenter", (event, d) => {
// Find all links connected to this node
const connectedLinks = sankeyData.links.filter(link =>
link.source === d || link.target === d
);
applyHoverEffect(connectedLinks, linkPaths, nodeGroups);
this.#showNodeTooltip(event, d);
})
.on("mousemove", (event) => this.#updateTooltipPosition(event))
.on("mouseleave", () => {
resetHoverEffect(linkPaths, nodeGroups);
this.#hideTooltip();
})
.each(function (d) {
const textElement = d3.select(this);
textElement.selectAll("tspan").remove();
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1)
.style("transition", "opacity 0.2s ease")
.each(function(d) {
const textEl = d3.select(this);
textEl.selectAll("tspan").remove();
// Node Name on the first line
textElement.append("tspan")
.text(d.name);
textEl.append("tspan").text(d.name);
// Financial details on the second line
const financialDetailsTspan = textElement.append("tspan")
.attr("x", textElement.attr("x"))
textEl.append("tspan")
.attr("x", textEl.attr("x"))
.attr("dy", "1.2em")
.attr("class", "font-mono text-secondary")
.style("font-size", "0.65rem"); // Explicitly set smaller font size
.style("font-size", "0.65rem")
.text(controller.#formatCurrency(d.value));
});
financialDetailsTspan.append("tspan")
.text(stimulusControllerInstance.currencySymbolValue + Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
return hiddenLabels;
}
// Calculate which labels should be hidden to prevent overlap
#calculateHiddenLabels(nodes) {
const hiddenLabels = new Set();
const minSpacing = this.constructor.MIN_LABEL_SPACING;
// Group nodes by column (using depth which d3-sankey assigns)
const columns = new Map();
nodes.forEach(node => {
const depth = node.depth;
if (!columns.has(depth)) columns.set(depth, []);
columns.get(depth).push(node);
});
// For each column, check for overlapping labels
columns.forEach(columnNodes => {
// Sort by vertical position
columnNodes.sort((a, b) => ((a.y0 + a.y1) / 2) - ((b.y0 + b.y1) / 2));
let lastVisibleY = Number.NEGATIVE_INFINITY;
columnNodes.forEach(node => {
const nodeY = (node.y0 + node.y1) / 2;
if (nodeY - lastVisibleY < minSpacing) {
// Too close to previous visible label, hide this one
hiddenLabels.add(node.index);
} else {
lastVisibleY = nodeY;
}
});
});
return hiddenLabels;
}
#attachHoverEvents(linkPaths, nodeGroups, sankeyData, hiddenLabels) {
const applyHover = (targetLinks) => {
const targetSet = new Set(targetLinks);
const connectedNodes = new Set(targetLinks.flatMap(l => [l.source, l.target]));
linkPaths
.style("opacity", d => targetSet.has(d) ? 1 : this.constructor.HOVER_OPACITY)
.style("filter", d => targetSet.has(d) ? this.constructor.HOVER_FILTER : "none");
nodeGroups.style("opacity", d => connectedNodes.has(d) ? 1 : this.constructor.HOVER_OPACITY);
// Show labels for connected nodes (even if normally hidden)
nodeGroups.selectAll("text")
.style("opacity", d => connectedNodes.has(d) ? 1 : (hiddenLabels.has(d.index) ? 0 : this.constructor.HOVER_OPACITY));
};
const resetHover = () => {
linkPaths.style("opacity", 1).style("filter", "none");
nodeGroups.style("opacity", 1);
// Restore hidden labels to hidden state
nodeGroups.selectAll("text")
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1);
};
linkPaths
.on("mouseenter", (event, d) => {
applyHover([d]);
this.#showTooltip(event, d.value, d.percentage);
})
.on("mousemove", event => this.#updateTooltipPosition(event))
.on("mouseleave", () => {
resetHover();
this.#hideTooltip();
});
// Hover on node rectangles (not just text)
nodeGroups.selectAll("path")
.style("cursor", "default")
.on("mouseenter", (event, d) => {
const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d);
applyHover(connectedLinks);
this.#showTooltip(event, d.value, d.percentage, d.name);
})
.on("mousemove", event => this.#updateTooltipPosition(event))
.on("mouseleave", () => {
resetHover();
this.#hideTooltip();
});
nodeGroups.selectAll("text")
.on("mouseenter", (event, d) => {
const connectedLinks = sankeyData.links.filter(l => l.source === d || l.target === d);
applyHover(connectedLinks);
this.#showTooltip(event, d.value, d.percentage, d.name);
})
.on("mousemove", event => this.#updateTooltipPosition(event))
.on("mouseleave", () => {
resetHover();
this.#hideTooltip();
});
}
// Tooltip methods
#createTooltip() {
// Create tooltip element once and reuse it
this.tooltip = d3.select("body")
.append("div")
.attr("class", "bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50")
@@ -270,30 +345,15 @@ export default class extends Controller {
.style("pointer-events", "none");
}
#showTooltip(event, linkData) {
this.#displayTooltip(event, linkData.value, linkData.percentage);
}
#showTooltip(event, value, percentage, title = null) {
if (!this.tooltip) this.#createTooltip();
#showNodeTooltip(event, nodeData) {
this.#displayTooltip(event, nodeData.value, nodeData.percentage, nodeData.name);
}
#displayTooltip(event, value, percentage, title = null) {
if (!this.tooltip) {
this.#createTooltip();
}
// Format the tooltip content
const formattedValue = this.currencySymbolValue + Number.parseFloat(value).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
const formattedValue = this.#formatCurrency(value);
const percentageText = percentage ? `${percentage}%` : "0%";
const content = title
const content = title
? `${title}<br/>${formattedValue} (${percentageText})`
: `${formattedValue} (${percentageText})`;
this.tooltip
.html(content)
.style("left", `${event.pageX + 10}px`)
@@ -304,19 +364,23 @@ export default class extends Controller {
}
#updateTooltipPosition(event) {
if (this.tooltip) {
this.tooltip
.style("left", `${event.pageX + 10}px`)
.style("top", `${event.pageY - 10}px`);
}
this.tooltip
?.style("left", `${event.pageX + 10}px`)
.style("top", `${event.pageY - 10}px`);
}
#hideTooltip() {
if (this.tooltip) {
this.tooltip
.transition()
.duration(100)
.style("opacity", 0);
}
this.tooltip
?.transition()
.duration(100)
.style("opacity", 0);
}
}
#formatCurrency(value) {
const formatted = Number.parseFloat(value).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
return this.currencySymbolValue + formatted;
}
}

View File

@@ -4,6 +4,7 @@ class Trade < ApplicationRecord
monetize :price
belongs_to :security
belongs_to :category, optional: true
# Use the same activity labels as Transaction
ACTIVITY_LABELS = Transaction::ACTIVITY_LABELS.dup.freeze

View File

@@ -0,0 +1,85 @@
<%# Renders a breakdown table for income or expense groups %>
<%# Local variables: groups, total, type (:income or :expense), amount_sort_params, current_sort_by, current_sort_direction %>
<%
color_class = type == :income ? "text-success" : "text-destructive"
icon_name = type == :income ? "trending-up" : "trending-down"
title_key = type == :income ? "reports.transactions_breakdown.table.income" : "reports.transactions_breakdown.table.expense"
%>
<div>
<h3 class="text-base font-semibold <%= color_class %> mb-4 flex items-center gap-2">
<%= icon(icon_name, class: "w-5 h-5") %>
<%= t(title_key) %>
<span class="text-sm font-normal text-tertiary">(<%= Money.new(total, Current.family.currency).format %>)</span>
</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-tertiary">
<th class="text-left py-3 pr-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.category") %></th>
<th class="text-right py-3 px-4 font-medium text-secondary">
<%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %>
<%= t("reports.transactions_breakdown.table.amount") %>
<% if current_sort_by == "amount" %>
<%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %>
<% end %>
<% end %>
</th>
<th class="text-right py-3 pl-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.percentage") %></th>
</tr>
</thead>
<tbody>
<% groups.each do |group| %>
<% percentage = total.zero? ? 0 : (group[:total].to_f / total * 100).round(1) %>
<% has_subcategories = group[:subcategories].present? && group[:subcategories].any? %>
<tr class="border-b border-tertiary hover:bg-surface-inset">
<td class="py-3 pr-4">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= group[:category_color] %>"></span>
<span class="font-medium text-primary"><%= group[:category_name] %></span>
<span class="text-xs text-tertiary whitespace-nowrap">(<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)</span>
</div>
</td>
<td class="py-3 px-4 text-right">
<span class="font-semibold <%= color_class %>">
<%= Money.new(group[:total], Current.family.currency).format %>
</span>
</td>
<td class="py-3 pl-4 text-right">
<span class="text-sm text-secondary">
<%= percentage %>%
</span>
</td>
</tr>
<%# Render subcategories if present %>
<% if has_subcategories %>
<% group[:subcategories].each do |subcategory| %>
<% sub_percentage = total.zero? ? 0 : (subcategory[:total].to_f / total * 100).round(1) %>
<tr class="border-b border-tertiary hover:bg-surface-inset bg-surface-inset/30">
<td class="py-2 pr-4 pl-6">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full flex-shrink-0" style="background-color: <%= subcategory[:category_color] %>"></span>
<span class="text-sm text-secondary"><%= subcategory[:category_name] %></span>
<span class="text-xs text-tertiary whitespace-nowrap">(<%= t("reports.transactions_breakdown.table.entries", count: subcategory[:count]) %>)</span>
</div>
</td>
<td class="py-2 px-4 text-right">
<span class="text-sm <%= color_class %>">
<%= Money.new(subcategory[:total], Current.family.currency).format %>
</span>
</td>
<td class="py-2 pl-4 text-right">
<span class="text-xs text-tertiary">
<%= sub_percentage %>%
</span>
</td>
</tr>
<% end %>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>

View File

@@ -54,110 +54,24 @@
<div class="space-y-8">
<%# Income Section %>
<% if income_groups.any? %>
<div>
<h3 class="text-base font-semibold text-success mb-4 flex items-center gap-2">
<%= icon("trending-up", class: "w-5 h-5") %>
<%= t("reports.transactions_breakdown.table.income") %>
<span class="text-sm font-normal text-tertiary">(<%= Money.new(income_total, Current.family.currency).format %>)</span>
</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-tertiary">
<th class="text-left py-3 pr-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.category") %></th>
<th class="text-right py-3 px-4 font-medium text-secondary">
<%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %>
<%= t("reports.transactions_breakdown.table.amount") %>
<% if current_sort_by == "amount" %>
<%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %>
<% end %>
<% end %>
</th>
<th class="text-right py-3 pl-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.percentage") %></th>
</tr>
</thead>
<tbody>
<% income_groups.each do |group| %>
<% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(1) %>
<tr class="border-b border-tertiary hover:bg-surface-inset">
<td class="py-3 pr-4">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= group[:category_color] %>"></span>
<span class="font-medium text-primary"><%= group[:category_name] %></span>
<span class="text-xs text-tertiary whitespace-nowrap">(<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)</span>
</div>
</td>
<td class="py-3 px-4 text-right">
<span class="font-semibold text-success">
<%= Money.new(group[:total], Current.family.currency).format %>
</span>
</td>
<td class="py-3 pl-4 text-right">
<span class="text-sm text-secondary">
<%= percentage %>%
</span>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<%= render "reports/breakdown_table",
groups: income_groups,
total: income_total,
type: :income,
amount_sort_params: amount_sort_params,
current_sort_by: current_sort_by,
current_sort_direction: current_sort_direction %>
<% end %>
<%# Expenses Section %>
<% if expense_groups.any? %>
<div>
<h3 class="text-base font-semibold text-destructive mb-4 flex items-center gap-2">
<%= icon("trending-down", class: "w-5 h-5") %>
<%= t("reports.transactions_breakdown.table.expense") %>
<span class="text-sm font-normal text-tertiary">(<%= Money.new(expense_total, Current.family.currency).format %>)</span>
</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-tertiary">
<th class="text-left py-3 pr-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.category") %></th>
<th class="text-right py-3 px-4 font-medium text-secondary">
<%= link_to reports_path(amount_sort_params), class: "inline-flex items-center gap-1 hover:text-primary" do %>
<%= t("reports.transactions_breakdown.table.amount") %>
<% if current_sort_by == "amount" %>
<%= icon(current_sort_direction == "desc" ? "chevron-down" : "chevron-up", class: "w-3 h-3") %>
<% end %>
<% end %>
</th>
<th class="text-right py-3 pl-4 font-medium text-secondary"><%= t("reports.transactions_breakdown.table.percentage") %></th>
</tr>
</thead>
<tbody>
<% expense_groups.each do |group| %>
<% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(1) %>
<tr class="border-b border-tertiary hover:bg-surface-inset">
<td class="py-3 pr-4">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= group[:category_color] %>"></span>
<span class="font-medium text-primary"><%= group[:category_name] %></span>
<span class="text-xs text-tertiary whitespace-nowrap">(<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)</span>
</div>
</td>
<td class="py-3 px-4 text-right">
<span class="font-semibold text-destructive">
<%= Money.new(group[:total], Current.family.currency).format %>
</span>
</td>
<td class="py-3 pl-4 text-right">
<span class="text-sm text-secondary">
<%= percentage %>%
</span>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<%= render "reports/breakdown_table",
groups: expense_groups,
total: expense_total,
type: :expense,
amount_sort_params: amount_sort_params,
current_sort_by: current_sort_by,
current_sort_direction: current_sort_direction %>
<% end %>
</div>

View File

@@ -1,8 +1,11 @@
require "test_helper"
class PagesControllerTest < ActionDispatch::IntegrationTest
include EntriesTestHelper
setup do
sign_in @user = users(:family_admin)
@family = @user.family
end
test "dashboard" do
@@ -10,6 +13,20 @@ class PagesControllerTest < ActionDispatch::IntegrationTest
assert_response :ok
end
test "dashboard renders sankey chart with subcategories" do
# Create parent category with subcategory
parent_category = @family.categories.create!(name: "Shopping", classification: "expense", color: "#FF5733")
subcategory = @family.categories.create!(name: "Groceries", classification: "expense", parent: parent_category, color: "#33FF57")
# Create transactions using helper
create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category)
create_transaction(account: @family.accounts.first, name: "Grocery store", amount: 50, category: subcategory)
get root_path
assert_response :ok
assert_select "[data-controller='sankey-chart']"
end
test "changelog" do
VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do
get changelog_path

View File

@@ -1,6 +1,8 @@
require "test_helper"
class ReportsControllerTest < ActionDispatch::IntegrationTest
include EntriesTestHelper
setup do
sign_in @user = users(:family_admin)
@family = @user.family
@@ -221,4 +223,19 @@ class ReportsControllerTest < ActionDispatch::IntegrationTest
# Verify the CSV content is generated (should not crash)
assert_not_nil @response.body
end
test "index groups transactions by parent and subcategories" do
# Create parent category with subcategories
parent_category = @family.categories.create!(name: "Entertainment", classification: "expense", color: "#FF5733")
subcategory_movies = @family.categories.create!(name: "Movies", classification: "expense", parent: parent_category, color: "#33FF57")
subcategory_games = @family.categories.create!(name: "Games", classification: "expense", parent: parent_category, color: "#5733FF")
# Create transactions using helper
create_transaction(account: @family.accounts.first, name: "Cinema ticket", amount: 15, category: subcategory_movies)
create_transaction(account: @family.accounts.first, name: "Video game", amount: 60, category: subcategory_games)
get reports_path(period_type: :monthly)
assert_response :ok
assert_select "table.w-full"
end
end