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" +%> + +
+

+ <%= icon(icon_name, class: "w-5 h-5") %> + <%= t(title_key) %> + (<%= Money.new(total, Current.family.currency).format %>) +

+ +
+ + + + + + + + + + <% groups.each do |group| %> + <% percentage = total.zero? ? 0 : (group[:total].to_f / total * 100).round(1) %> + <% has_subcategories = group[:subcategories].present? && group[:subcategories].any? %> + + + + + + <%# 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) %> + + + + + + <% end %> + <% end %> + <% end %> + +
<%= 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 %>% + +
+
+
diff --git a/app/views/reports/_transactions_breakdown.html.erb b/app/views/reports/_transactions_breakdown.html.erb index 200ff60df..66c3d0e18 100644 --- a/app/views/reports/_transactions_breakdown.html.erb +++ b/app/views/reports/_transactions_breakdown.html.erb @@ -54,110 +54,24 @@
<%# Income Section %> <% if income_groups.any? %> -
-

- <%= icon("trending-up", class: "w-5 h-5") %> - <%= t("reports.transactions_breakdown.table.income") %> - (<%= Money.new(income_total, Current.family.currency).format %>) -

- -
- - - - - - - - - - <% income_groups.each do |group| %> - <% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(1) %> - - - - - - <% end %> - -
<%= 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 %>% - -
-
-
+ <%= 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? %> -
-

- <%= icon("trending-down", class: "w-5 h-5") %> - <%= t("reports.transactions_breakdown.table.expense") %> - (<%= Money.new(expense_total, Current.family.currency).format %>) -

- -
- - - - - - - - - - <% expense_groups.each do |group| %> - <% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(1) %> - - - - - - <% end %> - -
<%= 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 %>% - -
-
-
+ <%= 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 %>
diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index 5e178351c..8aa72c41f 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -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 diff --git a/test/controllers/reports_controller_test.rb b/test/controllers/reports_controller_test.rb index 7683055b0..a4524382d 100644 --- a/test/controllers/reports_controller_test.rb +++ b/test/controllers/reports_controller_test.rb @@ -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