diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
index db76e9d1d..b1053b026 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -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
diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb
index 6d22b7971..763dd68f2 100644
--- a/app/controllers/reports_controller.rb
+++ b/app/controllers/reports_controller.rb
@@ -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
diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js
index 952941397..f97714ac9 100644
--- a/app/javascript/controllers/sankey_chart_controller.js
+++ b/app/javascript/controllers/sankey_chart_controller.js
@@ -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}
${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;
+ }
+}
diff --git a/app/models/trade.rb b/app/models/trade.rb
index 5357b0453..10159d201 100644
--- a/app/models/trade.rb
+++ b/app/models/trade.rb
@@ -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
diff --git a/app/views/reports/_breakdown_table.html.erb b/app/views/reports/_breakdown_table.html.erb
new file mode 100644
index 000000000..83d15350d
--- /dev/null
+++ b/app/views/reports/_breakdown_table.html.erb
@@ -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"
+%>
+
+
| <%= t("reports.transactions_breakdown.table.category") %> | ++ <%= 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 %> + | +<%= t("reports.transactions_breakdown.table.percentage") %> | +
|---|---|---|
|
+
+
+ <%= group[:category_name] %>
+ (<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)
+
+ |
+ + + <%= Money.new(group[:total], Current.family.currency).format %> + + | ++ + <%= percentage %>% + + | +
|
+
+
+ <%= subcategory[:category_name] %>
+ (<%= t("reports.transactions_breakdown.table.entries", count: subcategory[:count]) %>)
+
+ |
+ + + <%= Money.new(subcategory[:total], Current.family.currency).format %> + + | ++ + <%= sub_percentage %>% + + | +
| <%= t("reports.transactions_breakdown.table.category") %> | -- <%= 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 %> - | -<%= t("reports.transactions_breakdown.table.percentage") %> | -
|---|---|---|
|
-
-
- <%= group[:category_name] %>
- (<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)
-
- |
- - - <%= Money.new(group[:total], Current.family.currency).format %> - - | -- - <%= percentage %>% - - | -
| <%= t("reports.transactions_breakdown.table.category") %> | -- <%= 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 %> - | -<%= t("reports.transactions_breakdown.table.percentage") %> | -
|---|---|---|
|
-
-
- <%= group[:category_name] %>
- (<%= t("reports.transactions_breakdown.table.entries", count: group[:count]) %>)
-
- |
- - - <%= Money.new(group[:total], Current.family.currency).format %> - - | -- - <%= percentage %>% - - | -