Implement an outflows section (#220)

This commit is contained in:
soky srm
2025-10-23 14:42:25 +02:00
committed by GitHub
parent 11d4862fb3
commit 4999409082
5 changed files with 251 additions and 30 deletions

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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 %>">
<head>
<%= render "layouts/shared/head" %>
<%= yield :head %>
</head>
<body class="h-full overflow-hidden lg:overflow-auto antialiased">
<body class="h-full overflow-hidden antialiased">
<% if Rails.env.development? %>
<button hidden data-controller="hotkey" data-hotkey="t t /" data-action="theme#toggle"></button>
<% end %>

View File

@@ -34,6 +34,17 @@
</section>
<% end %>
<% if @outflows_data[:categories].present? %>
<%= turbo_frame_tag "outflows_donut_section" do %>
<section class="bg-container py-4 rounded-xl shadow-border-xs mb-6">
<%= render partial: "pages/dashboard/outflows_donut", locals: {
outflows_data: @outflows_data,
period: @outflows_period
} %>
</section>
<% end %>
<% end %>
<section class="bg-container py-4 rounded-xl shadow-border-xs">
<%= render partial: "pages/dashboard/net_worth_chart", locals: {
balance_sheet: @balance_sheet,

View File

@@ -0,0 +1,87 @@
<%# locals: (outflows_data:, period:) %>
<div id="outflows-donut-section">
<div class="flex justify-between items-center gap-4 px-4 mb-4">
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
Outflows
</h2>
<%= 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 %>
</div>
<div class="px-4">
<div class="flex flex-col lg:flex-row gap-8 items-center">
<!-- Donut Chart -->
<div class="w-full lg:w-1/3 max-w-[300px]">
<div class="h-[300px] relative"
data-controller="donut-chart"
data-donut-chart-segments-value="<%= outflows_data[:categories].to_json %>"
data-donut-chart-segment-height-value="5"
data-donut-chart-segment-opacity-value="0.9"
data-donut-chart-extended-hover-value="true"
data-donut-chart-hover-extension-value="3"
data-donut-chart-enable-click-value="true"
data-donut-chart-start-date-value="<%= period.date_range.first %>"
data-donut-chart-end-date-value="<%= period.date_range.last %>">
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full">
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center">
<div class="text-secondary text-sm mb-2">
<span>Total Outflows</span>
</div>
<div class="text-3xl font-medium text-primary">
<%= outflows_data[:currency_symbol] %><%= number_with_delimiter(outflows_data[:total], delimiter: ',') %>
</div>
</div>
<% outflows_data[:categories].each do |category| %>
<div id="segment_<%= category[:id] %>" class="hidden">
<div class="flex flex-col gap-2 items-center">
<p class="text-sm text-secondary"><%= category[:name] %></p>
<p class="text-3xl font-medium text-primary">
<%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %>
</p>
<p class="text-sm text-secondary"><%= category[:percentage] %>%</p>
</div>
</div>
<% end %>
</div>
</div>
</div>
<!-- Category List -->
<div class="w-full lg:w-2/3">
<div class="space-y-2">
<% 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 %>
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= category[:color] %>"></div>
<%= icon(category[:icon], class: "w-4 h-4 text-primary flex-shrink-0") %>
<span class="text-sm font-medium text-primary truncate"><%= category[:name] %></span>
</div>
<div class="flex items-center gap-4 flex-shrink-0">
<span class="text-sm font-medium text-primary whitespace-nowrap"><%= outflows_data[:currency_symbol] %><%= number_with_delimiter(category[:amount], delimiter: ',') %></span>
<span class="text-sm text-secondary whitespace-nowrap"><%= category[:percentage] %>%</span>
</div>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
</div>