diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 52e180425..07a7d10f0 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -7,10 +7,23 @@ class PagesController < ApplicationController @balance_sheet = Current.family.balance_sheet @accounts = Current.family.accounts.visible.with_attached_logo - period_param = params[:cashflow_period] - @cashflow_period = if period_param.present? + # Handle cashflow period + cashflow_period_param = params[:cashflow_period] + @cashflow_period = if cashflow_period_param.present? begin - Period.from_key(period_param) + Period.from_key(cashflow_period_param) + rescue Period::InvalidKeyError + Period.last_30_days + end + else + Period.last_30_days + end + + # Handle outflows period + outflows_period_param = params[:outflows_period] + @outflows_period = if outflows_period_param.present? + begin + Period.from_key(outflows_period_param) rescue Period::InvalidKeyError Period.last_30_days end @@ -19,10 +32,15 @@ class PagesController < ApplicationController 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) + # Get data for cashflow section + income_totals = Current.family.income_statement.income_totals(period: @cashflow_period) + cashflow_expense_totals = Current.family.income_statement.expense_totals(period: @cashflow_period) + @cashflow_sankey_data = build_cashflow_sankey_data(income_totals, cashflow_expense_totals, family_currency) + + # Get data for outflows section (using its own period) + outflows_expense_totals = Current.family.income_statement.expense_totals(period: @outflows_period) + @outflows_data = build_outflows_donut_data(outflows_expense_totals) @breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ] end @@ -152,4 +170,26 @@ class PagesController < ApplicationController { nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol } end + + def build_outflows_donut_data(expense_totals) + currency_symbol = Money::Currency.new(expense_totals.currency).symbol + total = expense_totals.total + + # Only include top-level categories with non-zero amounts + categories = expense_totals.category_totals + .reject { |ct| ct.category.parent_id.present? || ct.total.zero? } + .sort_by { |ct| -ct.total } + .map do |ct| + { + id: ct.category.id, + name: ct.category.name, + amount: ct.total.to_f.round(2), + percentage: ct.weight.round(1), + color: ct.category.color.presence || Category::UNCATEGORIZED_COLOR, + icon: ct.category.lucide_icon + } + end + + { categories: categories, total: total.to_f.round(2), currency_symbol: currency_symbol } + end end diff --git a/app/javascript/controllers/donut_chart_controller.js b/app/javascript/controllers/donut_chart_controller.js index 0c52104b4..216e7d9d6 100644 --- a/app/javascript/controllers/donut_chart_controller.js +++ b/app/javascript/controllers/donut_chart_controller.js @@ -10,10 +10,17 @@ export default class extends Controller { overageSegmentId: { type: String, default: "overage" }, segmentHeight: { type: Number, default: 3 }, segmentOpacity: { type: Number, default: 1 }, + extendedHover: { type: Boolean, default: false }, + hoverExtension: { type: Number, default: 3 }, + enableClick: { type: Boolean, default: false }, + startDate: String, + endDate: String, }; #viewBoxSize = 100; - #minSegmentAngle = this.segmentHeightValue * 0.01; + #minSegmentAngle = 0.02; // Minimum angle in radians (~1.15 degrees) + #padAngle = 0.005; // Spacing between segments (~0.29 degrees) + #visiblePaths = null; connect() { this.#draw(); @@ -40,7 +47,7 @@ export default class extends Controller { ...s, amount: Math.max( Number(s.amount), - totalPieValue * this.#minSegmentAngle, + totalPieValue * (this.#minSegmentAngle / (2 * Math.PI)), ), })) .sort((a, b) => { @@ -58,10 +65,15 @@ export default class extends Controller { }; #teardown() { - d3.select(this.chartContainerTarget).selectAll("*").remove(); + if (this.hasChartContainerTarget) { + d3.select(this.chartContainerTarget).selectAll("*").remove(); + } + this.#visiblePaths = null; } #draw() { + if (!this.hasChartContainerTarget) return; + const svg = d3 .select(this.chartContainerTarget) .append("svg") @@ -79,37 +91,73 @@ export default class extends Controller { .innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue) .outerRadius(this.#viewBoxSize / 2) .cornerRadius(this.segmentHeightValue) - .padAngle(this.#minSegmentAngle); + .padAngle(this.#padAngle); - const segmentArcs = svg + const g = svg .append("g") .attr( "transform", `translate(${this.#viewBoxSize / 2}, ${this.#viewBoxSize / 2})`, - ) + ); + + const segmentGroups = g .selectAll("arc") .data(pie(this.#data)) .enter() .append("g") - .attr("class", "arc pointer-events-auto") + .attr("class", "arc pointer-events-auto"); + + // Add invisible hover paths with extended area if enabled + if (this.extendedHoverValue) { + const hoverArc = d3 + .arc() + .innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue - this.hoverExtensionValue) + .outerRadius(this.#viewBoxSize / 2 + this.hoverExtensionValue) + .padAngle(this.#padAngle); + + segmentGroups + .append("path") + .attr("class", "hover-path") + .attr("d", hoverArc) + .attr("fill", "transparent") + .attr("data-segment-id", (d) => d.data.id) + .style("pointer-events", "all"); + } + + // Add visible paths + const segmentArcs = segmentGroups .append("path") + .attr("class", "visible-path") .attr("data-segment-id", (d) => d.data.id) .attr("data-original-color", this.#transformRingColor) .attr("fill", this.#transformRingColor) .attr("d", mainArc); + // Disable pointer events on visible paths if extended hover is enabled + if (this.extendedHoverValue) { + segmentArcs.style("pointer-events", "none"); + } + + // Cache the visible paths selection for performance + this.#visiblePaths = d3.select(this.chartContainerTarget).selectAll("path.visible-path"); + // Ensures that user can click on default content without triggering hover on a segment if that is their intent let hoverTimeout = null; - segmentArcs + segmentGroups .on("mouseover", (event) => { hoverTimeout = setTimeout(() => { this.#clearSegmentHover(); this.#handleSegmentHover(event); - }, 150); + }, 10); }) .on("mouseleave", () => { clearTimeout(hoverTimeout); + }) + .on("click", (event, d) => { + if (this.enableClickValue) { + this.#handleClick(d.data); + } }); } @@ -131,19 +179,20 @@ export default class extends Controller { if (!template) return; - d3.select(this.chartContainerTarget) - .selectAll("path") - .attr("fill", function () { - if (this.dataset.segmentId === segmentId) { - if (this.dataset.segmentId === unusedSegmentId) { - return "var(--budget-unused-fill)"; - } + // Use cached selection if available for better performance + const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path"); - return this.dataset.originalColor; + paths.attr("fill", function () { + if (this.dataset.segmentId === segmentId) { + if (this.dataset.segmentId === unusedSegmentId) { + return "var(--budget-unused-fill)"; } - return "var(--budget-unallocated-fill)"; - }); + return this.dataset.originalColor; + } + + return "var(--budget-unallocated-fill)"; + }); this.defaultContentTarget.classList.add("hidden"); template.classList.remove("hidden"); @@ -153,11 +202,14 @@ export default class extends Controller { #clearSegmentHover = () => { this.defaultContentTarget.classList.remove("hidden"); - d3.select(this.chartContainerTarget) - .selectAll("path") + // Use cached selection if available for better performance + const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path"); + + paths .attr("fill", function () { return this.dataset.originalColor; - }); + }) + .style("opacity", null); // Clear inline opacity style for (const child of this.contentContainerTarget.children) { if (child !== this.defaultContentTarget) { @@ -165,4 +217,35 @@ export default class extends Controller { } } }; + + // Handles click on segment (optional, controlled by enableClick value) + #handleClick(segment) { + if (!segment.name || !this.startDateValue || !this.endDateValue) return; + + const segmentName = encodeURIComponent(segment.name); + const startDate = this.startDateValue; + const endDate = this.endDateValue; + + const url = `/transactions?q[categories][]=${segmentName}&q[start_date]=${startDate}&q[end_date]=${endDate}`; + window.location.href = url; + } + + // Public methods for external highlighting (e.g., from category list hover) + highlightSegment(event) { + const segmentId = event.currentTarget.dataset.categoryId; + + // Use cached selection if available for better performance + const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path"); + + paths.style("opacity", function() { + return this.dataset.segmentId === segmentId ? 1 : 0.3; + }); + } + + unhighlightSegment() { + // Use cached selection if available for better performance + const paths = this.#visiblePaths || d3.select(this.chartContainerTarget).selectAll("path.visible-path"); + + paths.style("opacity", null); // Clear inline opacity style + } } diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index e45d91f78..25b0ba773 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -7,13 +7,13 @@ data-theme="<%= theme %>" data-controller="theme" data-theme-user-preference-value="<%= Current.user&.theme || "system" %>" - class="h-full text-primary overflow-hidden lg:overflow-auto font-sans <%= @os %>"> + class="h-full text-primary overflow-hidden font-sans <%= @os %>"> <%= render "layouts/shared/head" %> <%= yield :head %> - + <% if Rails.env.development? %> <% end %> diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 4e06b2705..3951d6106 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -34,6 +34,17 @@ <% end %> + <% if @outflows_data[:categories].present? %> + <%= turbo_frame_tag "outflows_donut_section" do %> +
+ <%= render partial: "pages/dashboard/outflows_donut", locals: { + outflows_data: @outflows_data, + period: @outflows_period + } %> +
+ <% end %> + <% end %> +
<%= render partial: "pages/dashboard/net_worth_chart", locals: { balance_sheet: @balance_sheet, diff --git a/app/views/pages/dashboard/_outflows_donut.html.erb b/app/views/pages/dashboard/_outflows_donut.html.erb new file mode 100644 index 000000000..e89f4d4fe --- /dev/null +++ b/app/views/pages/dashboard/_outflows_donut.html.erb @@ -0,0 +1,87 @@ +<%# locals: (outflows_data:, period:) %> +
+
+

+ Outflows +

+ + <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "outflows_donut_section" } do |form| %> + <%= form.select :outflows_period, + Period.as_options, + { selected: period.key }, + data: { "auto-submit-form-target": "auto" }, + class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %> + <% end %> +
+ +
+
+ +
+
+
+ +
+
+
+ Total Outflows +
+ +
+ <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(outflows_data[:total], delimiter: ',') %> +
+
+ + <% outflows_data[:categories].each do |category| %> + + <% end %> +
+
+
+ + +
+
+ <% outflows_data[:categories].each do |category| %> + <%= link_to transactions_path(q: { categories: [category[:name]], start_date: period.date_range.first, end_date: period.date_range.last }), + class: "flex items-center justify-between p-3 rounded-lg hover:bg-container-inset transition-colors cursor-pointer group", + data: { + turbo_frame: "_top", + category_id: category[:id], + action: "mouseenter->donut-chart#highlightSegment mouseleave->donut-chart#unhighlightSegment" + } do %> +
+
+ <%= icon(category[:icon], class: "w-4 h-4 text-primary flex-shrink-0") %> + <%= category[:name] %> +
+
+ <%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %> + <%= category[:percentage] %>% +
+ <% end %> + <% end %> +
+
+
+
+