diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
index c566d30e..1162483a 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -6,6 +6,23 @@ class PagesController < ApplicationController
@balance_sheet = Current.family.balance_sheet
@accounts = Current.family.accounts.active.with_attached_logo
+ period_param = params[:cashflow_period]
+ @cashflow_period = if period_param.present?
+ begin
+ Period.from_key(period_param)
+ rescue Period::InvalidKeyError
+ Period.last_30_days
+ end
+ else
+ Period.last_30_days
+ end
+
+ family_currency = Current.family.currency
+ income_totals = Current.family.income_statement.income_totals(period: @cashflow_period)
+ expense_totals = Current.family.income_statement.expense_totals(period: @cashflow_period)
+
+ @cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)
+
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
end
@@ -31,4 +48,98 @@ class PagesController < ApplicationController
def github_provider
Provider::Registry.get_provider(:github)
end
+
+ def build_cashflow_sankey_data(income_totals, expense_totals, currency_symbol)
+ nodes = []
+ links = []
+ node_indices = {} # Memoize node indices by a unique key: "type_categoryid"
+
+ # Helper to add/find node and return its index
+ add_node = ->(unique_key, display_name, value, percentage, color) {
+ node_indices[unique_key] ||= begin
+ nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }
+ nodes.size - 1
+ end
+ }
+
+ total_income_val = income_totals.total.to_f.round(2)
+ total_expense_val = expense_totals.total.to_f.round(2)
+
+ # --- Create Central Cash Flow Node ---
+ cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income_val, 0, "var(--color-success)")
+
+ # --- Process Income Side ---
+ income_category_values = Hash.new(0.0)
+ income_totals.category_totals.each do |ct|
+ val = ct.total.to_f.round(2)
+ next if val.zero? || !ct.category.parent_id
+ income_category_values[ct.category.parent_id] += val
+ end
+
+ income_totals.category_totals.each do |ct|
+ val = ct.total.to_f.round(2)
+ percentage_of_total_income = total_income_val.zero? ? 0 : (val / total_income_val * 100).round(1)
+ next if val.zero?
+
+ node_display_name = ct.category.name
+ node_value_for_label = val + income_category_values[ct.category.id] # This sum is for parent node display
+ node_percentage_for_label = total_income_val.zero? ? 0 : (node_value_for_label / total_income_val * 100).round(1)
+
+ node_color = ct.category.color.presence || Category::COLORS.sample
+ current_cat_idx = add_node.call("income_#{ct.category.id}", node_display_name, node_value_for_label, node_percentage_for_label, node_color)
+
+ if ct.category.parent_id
+ parent_cat_idx = node_indices["income_#{ct.category.parent_id}"]
+ parent_cat_idx ||= add_node.call("income_#{ct.category.parent.id}", ct.category.parent.name, income_category_values[ct.category.parent.id], 0, ct.category.parent.color || Category::COLORS.sample) # Parent percentage will be recalc based on its total flow
+ links << { source: current_cat_idx, target: parent_cat_idx, value: val, color: node_color, percentage: percentage_of_total_income }
+ else
+ links << { source: current_cat_idx, target: cash_flow_idx, value: val, color: node_color, percentage: percentage_of_total_income }
+ end
+ end
+
+ # --- Process Expense Side ---
+ expense_category_values = Hash.new(0.0)
+ expense_totals.category_totals.each do |ct|
+ val = ct.total.to_f.round(2)
+ next if val.zero? || !ct.category.parent_id
+ expense_category_values[ct.category.parent_id] += val
+ end
+
+ expense_totals.category_totals.each do |ct|
+ val = ct.total.to_f.round(2)
+ percentage_of_total_expense = total_expense_val.zero? ? 0 : (val / total_expense_val * 100).round(1)
+ next if val.zero?
+
+ node_display_name = ct.category.name
+ node_value_for_label = val + expense_category_values[ct.category.id]
+ node_percentage_for_label = total_expense_val.zero? ? 0 : (node_value_for_label / total_expense_val * 100).round(1) # Percentage relative to total expenses for expense nodes
+
+ node_color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
+ current_cat_idx = add_node.call("expense_#{ct.category.id}", node_display_name, node_value_for_label, node_percentage_for_label, node_color)
+
+ if ct.category.parent_id
+ parent_cat_idx = node_indices["expense_#{ct.category.parent_id}"]
+ parent_cat_idx ||= add_node.call("expense_#{ct.category.parent.id}", ct.category.parent.name, expense_category_values[ct.category.parent.id], 0, ct.category.parent.color || Category::UNCATEGORIZED_COLOR)
+ links << { source: parent_cat_idx, target: current_cat_idx, value: val, color: nodes[parent_cat_idx][:color], percentage: percentage_of_total_expense }
+ else
+ links << { source: cash_flow_idx, target: current_cat_idx, value: val, color: node_color, percentage: percentage_of_total_expense }
+ end
+ end
+
+ # --- Process Surplus ---
+ leftover = (total_income_val - total_expense_val).round(2)
+ if leftover.positive?
+ percentage_of_total_income_for_surplus = total_income_val.zero? ? 0 : (leftover / total_income_val * 100).round(1)
+ surplus_idx = add_node.call("surplus_node", "Surplus", leftover, percentage_of_total_income_for_surplus, "var(--color-success)")
+ links << { source: cash_flow_idx, target: surplus_idx, value: leftover, color: "var(--color-success)", percentage: percentage_of_total_income_for_surplus }
+ end
+
+ # Update Cash Flow and Income node percentages (relative to total income)
+ if node_indices["cash_flow_node"]
+ nodes[node_indices["cash_flow_node"]][:percentage] = 100.0
+ end
+ # No primary income node anymore, percentages are on individual income cats relative to total_income_val
+
+ { nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol }
+ end
end
diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js
new file mode 100644
index 00000000..9601b088
--- /dev/null
+++ b/app/javascript/controllers/sankey_chart_controller.js
@@ -0,0 +1,204 @@
+import { Controller } from "@hotwired/stimulus";
+import * as d3 from "d3";
+import { sankey, sankeyLinkHorizontal } from "d3-sankey";
+
+// Connects to data-controller="sankey-chart"
+export default class extends Controller {
+ static values = {
+ data: Object,
+ nodeWidth: { type: Number, default: 15 },
+ nodePadding: { type: Number, default: 20 },
+ currencySymbol: { type: String, default: "$" }
+ };
+
+ connect() {
+ this.resizeObserver = new ResizeObserver(() => this.#draw());
+ this.resizeObserver.observe(this.element);
+ this.#draw();
+ }
+
+ disconnect() {
+ this.resizeObserver?.disconnect();
+ }
+
+ #draw() {
+ const { nodes = [], links = [] } = this.dataValue || {};
+
+ if (!nodes.length || !links.length) return;
+
+ // 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)
+ .append("svg")
+ .attr("width", width)
+ .attr("height", height);
+
+ const sankeyGenerator = sankey()
+ .nodeWidth(this.nodeWidthValue)
+ .nodePadding(this.nodePaddingValue)
+ .extent([
+ [16, 16],
+ [width - 16, height - 16],
+ ]);
+
+ const sankeyData = sankeyGenerator({
+ nodes: nodes.map((d) => Object.assign({}, d)),
+ links: links.map((d) => Object.assign({}, d)),
+ });
+
+ // Define gradients for 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);
+
+ const gradient = defs.append("linearGradient")
+ .attr("id", gradientId)
+ .attr("gradientUnits", "userSpaceOnUse")
+ .attr("x1", link.source.x1)
+ .attr("x2", link.target.x0);
+
+ gradient.append("stop")
+ .attr("offset", "0%")
+ .attr("stop-color", sourceStopColor);
+
+ gradient.append("stop")
+ .attr("offset", "100%")
+ .attr("stop-color", targetStopColor);
+ });
+
+ // Draw links
+ svg
+ .append("g")
+ .attr("fill", "none")
+ .selectAll("path")
+ .data(sankeyData.links)
+ .join("path")
+ .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))
+ .append("title")
+ .text((d) => `${nodes[d.source.index].name} → ${nodes[d.target.index].name}: ${this.currencySymbolValue}${Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} (${d.percentage}%)`);
+
+ // Draw nodes
+ const node = svg
+ .append("g")
+ .selectAll("g")
+ .data(sankeyData.nodes)
+ .join("g");
+
+ const cornerRadius = 8;
+
+ node.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
+
+ // Dynamic corner radius based on node height, maxed at 8
+ const effectiveCornerRadius = Math.max(0, Math.min(cornerRadius, h / 2));
+
+ 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);
+
+ 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`;
+ }
+
+ 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`;
+ }
+
+ // 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
+ });
+
+ const stimulusControllerInstance = this;
+ node
+ .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("class", "text-xs font-medium text-primary fill-current")
+ .each(function (d) {
+ const textElement = d3.select(this);
+ textElement.selectAll("tspan").remove();
+
+ // Node Name on the first line
+ textElement.append("tspan")
+ .text(d.name);
+
+ // Financial details on the second line
+ const financialDetailsTspan = textElement.append("tspan")
+ .attr("x", textElement.attr("x"))
+ .attr("dy", "1.2em")
+ .attr("class", "font-mono text-secondary")
+ .style("font-size", "0.65rem"); // Explicitly set smaller font size
+
+ financialDetailsTspan.append("tspan")
+ .text(stimulusControllerInstance.currencySymbolValue + Number.parseFloat(d.value).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
+ });
+ }
+}
\ No newline at end of file
diff --git a/app/javascript/shims/d3-array-default.js b/app/javascript/shims/d3-array-default.js
new file mode 100644
index 00000000..1b1e088e
--- /dev/null
+++ b/app/javascript/shims/d3-array-default.js
@@ -0,0 +1,3 @@
+import * as d3Array from "d3-array-src";
+export * from "d3-array-src";
+export default d3Array;
\ No newline at end of file
diff --git a/app/javascript/shims/d3-shape-default.js b/app/javascript/shims/d3-shape-default.js
new file mode 100644
index 00000000..23920eda
--- /dev/null
+++ b/app/javascript/shims/d3-shape-default.js
@@ -0,0 +1,3 @@
+import * as d3Shape from "d3-shape-src";
+export * from "d3-shape-src";
+export default d3Shape;
\ No newline at end of file
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb
index 1f2f347e..82de3bd1 100644
--- a/app/views/pages/dashboard.html.erb
+++ b/app/views/pages/dashboard.html.erb
@@ -31,13 +31,21 @@
period: @period
} %>
+
F&&(F=a)}else if(p=(p+360)%360-180,g^(u*T
F&&(F=t)}if(g)n F&&(F=a)}else if(p=(p+360)%360-180,g^(u*T F&&(F=t)}if(g)n =0&&"xmlns"!==(n=t.slice(0,r))&&(t=t.slice(r+1));return e.hasOwnProperty(n)?{space:e[n],local:t}:t}function creatorInherit(e){return function(){var n=this.ownerDocument,r=this.namespaceURI;return r===t&&n.documentElement.namespaceURI===t?n.createElement(e):n.createElementNS(r,e)}}function creatorFixed(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function creator(t){var e=namespace(t);return(e.local?creatorFixed:creatorInherit)(e)}function none(){}function selector(t){return null==t?none:function(){return this.querySelector(t)}}function selection_select(t){"function"!==typeof t&&(t=selector(t));for(var e=this._groups,n=e.length,r=new Array(n),i=0;ii)if(Math.abs(d*p-r*o)>i&&e){let u=n-_,x=a-$,y=p*p+r*r,M=u*u+x*x,c=Math.sqrt(y),f=Math.sqrt(l),w=e*Math.tan((t-Math.acos((y+l-M)/(2*c*f)))/2),v=w/f,P=w/c;Math.abs(v-1)>i&&this._append`L${h+v*o},${s+v*d}`;this._append`A${e},${e},0,0,${+(d*u>o*x)},${this._x1=h+P*p},${this._y1=s+P*r}`}else this._append`L${this._x1=h},${this._y1=s}`;else;}arc(n,a,e,_,$,p){n=+n,a=+a,e=+e,p=!!p;if(e<0)throw new Error(`negative radius: ${e}`);let r=e*Math.cos(_),o=e*Math.sin(_),d=n+r,l=a+o,u=1^p,x=p?_-$:$-_;null===this._x1?this._append`M${d},${l}`:(Math.abs(this._x1-d)>i||Math.abs(this._y1-l)>i)&&this._append`L${d},${l}`;if(e){x<0&&(x=x%h+h);x>s?this._append`A${e},${e},0,1,${u},${n-r},${a-o}A${e},${e},0,1,${u},${this._x1=d},${this._y1=l}`:x>i&&this._append`A${e},${e},0,${+(x>=t)},${u},${this._x1=n+e*Math.cos($)},${this._y1=a+e*Math.sin($)}`}}rect(t,h,i,s){this._append`M${this._x0=this._x1=+t},${this._y0=this._y1=+h}h${i=+i}v${+s}h${-i}Z`}toString(){return this._}}function path(){return new Path}path.prototype=Path.prototype;function pathRound(t=3){return new Path(+t)}export{Path,path,pathRound};
+// d3-path@1.0.9 downloaded from https://ga.jspm.io/npm:d3-path@1.0.9/dist/d3-path.js
+
+var t="undefined"!==typeof globalThis?globalThis:"undefined"!==typeof self?self:global;var i={};(function(t,h){h(i)})(i,(function(i){var h=Math.PI,s=2*h,_=1e-6,n=s-_;function Path(){(this||t)._x0=(this||t)._y0=(this||t)._x1=(this||t)._y1=null;(this||t)._=""}function path(){return new Path}Path.prototype=path.prototype={constructor:Path,moveTo:function(i,h){(this||t)._+="M"+((this||t)._x0=(this||t)._x1=+i)+","+((this||t)._y0=(this||t)._y1=+h)},closePath:function(){if(null!==(this||t)._x1){(this||t)._x1=(this||t)._x0,(this||t)._y1=(this||t)._y0;(this||t)._+="Z"}},lineTo:function(i,h){(this||t)._+="L"+((this||t)._x1=+i)+","+((this||t)._y1=+h)},quadraticCurveTo:function(i,h,s,_){(this||t)._+="Q"+ +i+","+ +h+","+((this||t)._x1=+s)+","+((this||t)._y1=+_)},bezierCurveTo:function(i,h,s,_,n,a){(this||t)._+="C"+ +i+","+ +h+","+ +s+","+ +_+","+((this||t)._x1=+n)+","+((this||t)._y1=+a)},arcTo:function(i,s,n,a,e){i=+i,s=+s,n=+n,a=+a,e=+e;var o=(this||t)._x1,r=(this||t)._y1,u=n-i,f=a-s,c=o-i,l=r-s,x=c*c+l*l;if(e<0)throw new Error("negative radius: "+e);if(null===(this||t)._x1)(this||t)._+="M"+((this||t)._x1=i)+","+((this||t)._y1=s);else if(x>_)if(Math.abs(l*u-f*c)>_&&e){var y=n-o,M=a-r,p=u*u+f*f,v=y*y+M*M,d=Math.sqrt(p),b=Math.sqrt(x),P=e*Math.tan((h-Math.acos((p+x-v)/(2*d*b)))/2),T=P/b,g=P/d;Math.abs(T-1)>_&&((this||t)._+="L"+(i+T*c)+","+(s+T*l));(this||t)._+="A"+e+","+e+",0,0,"+ +(l*y>c*M)+","+((this||t)._x1=i+g*u)+","+((this||t)._y1=s+g*f)}else(this||t)._+="L"+((this||t)._x1=i)+","+((this||t)._y1=s);else;},arc:function(i,a,e,o,r,u){i=+i,a=+a,e=+e,u=!!u;var f=e*Math.cos(o),c=e*Math.sin(o),l=i+f,x=a+c,y=1^u,M=u?o-r:r-o;if(e<0)throw new Error("negative radius: "+e);null===(this||t)._x1?(this||t)._+="M"+l+","+x:(Math.abs((this||t)._x1-l)>_||Math.abs((this||t)._y1-x)>_)&&((this||t)._+="L"+l+","+x);if(e){M<0&&(M=M%s+s);M>n?(this||t)._+="A"+e+","+e+",0,1,"+y+","+(i-f)+","+(a-c)+"A"+e+","+e+",0,1,"+y+","+((this||t)._x1=l)+","+((this||t)._y1=x):M>_&&((this||t)._+="A"+e+","+e+",0,"+ +(M>=h)+","+y+","+((this||t)._x1=i+e*Math.cos(r))+","+((this||t)._y1=a+e*Math.sin(r)))}},rect:function(i,h,s,_){(this||t)._+="M"+((this||t)._x0=(this||t)._x1=+i)+","+((this||t)._y0=(this||t)._y1=+h)+"h"+ +s+"v"+ +_+"h"+-s+"Z"},toString:function(){return(this||t)._}};i.path=path;Object.defineProperty(i,"__esModule",{value:true})}));const h=i.path,s=i.__esModule;export default i;export{s as __esModule,h as path};
diff --git a/vendor/javascript/d3-polygon.js b/vendor/javascript/d3-polygon.js
index 4ec6cc5c..f9371c51 100644
--- a/vendor/javascript/d3-polygon.js
+++ b/vendor/javascript/d3-polygon.js
@@ -1,2 +1,4 @@
+// d3-polygon@3.0.1 downloaded from https://ga.jspm.io/npm:d3-polygon@3.0.1/src/index.js
+
function area(n){var r,e=-1,t=n.length,o=n[t-1],l=0;while(++e=0;)if(r=i[o]){s&&4^r.compareDocumentPosition(s)&&s.parentNode.insertBefore(r,s);s=r}return this}function selection_sort(t){t||(t=ascending);function compareNode(e,n){return e&&n?t(e.__data__,n.__data__):!e-!n}for(var e=this._groups,n=e.length,r=new Array(n),i=0;i=0)i[n]=n;return i}function stackValue(t,n){return t[n]}function stackSeries(t){const n=[];n.key=t;return n}function stack(){var t=constant([]),n=none,i=none$1,e=stackValue;function stack(s){var o,a,r=Array.from(t.apply(this,arguments),stackSeries),h=r.length,l=-1;for(const t of s)for(o=0,++l;o1?0:t<-1?_:Math.acos(t)}function asin(t){return t>=1?u:t<=-1?-u:Math.asin(t)}function arcInnerRadius(t){return t.innerRadius}function arcOuterRadius(t){return t.outerRadius}function arcStartAngle(t){return t.startAngle}function arcEndAngle(t){return t.endAngle}function arcPadAngle(t){return t&&t.padAngle}function intersect(t,n,i,e,a,s,o,r){var l=i-t,h=e-n,_=o-a,u=r-s,f=u*l-_*h;if(!(f*f0)for(var i,e=0,a,s,o,r,l,h=t[n[0]].length;e0&&(s=(a=t[n[0]]).length)>0){for(var i=0,e=1,a,s,o;es&&(s=a,i=n);return i}function ascending(t){var n=t.map(sum);return none$1(t).sort((function(t,i){return n[t]-n[i]}))}function sum(t){var n=0,i=-1,e=t.length,a;while(++i0))return a;let o;do{a.push(o=new Date(+n)),t(n,r),e(n)}while(o