Files
sure/app/controllers/pages_controller.rb
Michal Tajchert e21ab9819f feat(dashboard): zoom into cashflow sankey categories (#1807)
* feat(dashboard): zoom into cashflow sankey categories

Click a category node on the dashboard cashflow Sankey to focus on it and
its descendants only; a back button restores the full view. Clicking the
Cash Flow node zooms to the expense (outbound) side.

- Pure utility (app/javascript/utils/sankey_zoom.js) computes the
  descendant subgraph from a clicked node, with direction inferred by
  reachability from the cash flow node (outbound for expense, inbound
  for income).
- Stable node ids emitted from the controller so the JS can identify
  nodes across re-renders.
- Stimulus controller adds chart + zoomOutButton targets, fade
  transition, and only sets a pointer cursor when a node has children.
- Node:test coverage for expense, income, cash-flow, and malformed-data
  cases; \"type\": \"module\" added to package.json so the .js util is
  ESM-compatible under Node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(dashboard): extract cashflow sankey chart partial

Deduplicate sankey chart markup between inline and expanded dialog views,
and reset zoom state when chart data changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(js): rename sankey_zoom util to .mjs to drop project-wide ESM flag

Removes "type": "module" from package.json to avoid implicitly switching
every .js file in the project to ESM (a future footgun for any .js config
file added by Biome, Vite, etc.). Renames the utility to .mjs so node --test
can import the ES module directly, and adds an explicit importmap pin since
pin_all_from only globs .js/.jsm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(assets): register .mjs MIME type for Propshaft

Propshaft derives Content-Type from Mime::Type.lookup_by_extension, which
returns nil for :mjs by default. Browsers refuse to execute ES modules
served with an empty Content-Type, breaking the sankey_zoom util loaded
via importmap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:17:35 +02:00

345 lines
13 KiB
Ruby

class PagesController < ApplicationController
include Periodable
skip_authentication only: %i[redis_configuration_error privacy terms]
before_action :ensure_intro_guest!, only: :intro
def dashboard
if Current.user&.ui_layout_intro?
redirect_to chats_path and return
end
@balance_sheet = Current.family.balance_sheet
@investment_statement = Current.family.investment_statement
@accounts = Current.user.accessible_accounts.visible.with_attached_logo
family_currency = Current.family.currency
# Use IncomeStatement for all cashflow data (now includes categorized trades)
income_statement = Current.family.income_statement
income_totals = income_statement.income_totals(period: @period)
expense_totals = income_statement.expense_totals(period: @period)
net_totals = income_statement.net_category_totals(period: @period)
@cashflow_sankey_data = build_cashflow_sankey_data(net_totals, income_totals, expense_totals, family_currency)
@outflows_data = build_outflows_donut_data(net_totals)
@dashboard_sections = build_dashboard_sections
@breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.dashboard"), nil ] ]
end
def intro
@breadcrumbs = [ [ t("breadcrumbs.home"), chats_path ], [ t("breadcrumbs.intro"), nil ] ]
end
def update_preferences
if Current.user.update_dashboard_preferences(preferences_params)
head :ok
else
head :unprocessable_entity
end
end
def changelog
@release_notes = github_provider.fetch_latest_release_notes
# Fallback if no release notes are available
if @release_notes.nil?
@release_notes = {
avatar: "https://github.com/we-promise.png",
username: "we-promise",
name: "Release notes unavailable",
published_at: Date.current,
body: "<p>Unable to fetch the latest release notes at this time. Please check back later or visit our <a href='https://github.com/we-promise/sure/releases' target='_blank'>GitHub releases page</a> directly.</p>"
}
end
render layout: "settings"
end
def feedback
render layout: "settings"
end
def redis_configuration_error
render layout: "blank"
end
def privacy
render layout: "blank"
end
def terms
render layout: "blank"
end
private
def preferences_params
prefs = params.require(:preferences)
{}.tap do |permitted|
permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections]
permitted["section_order"] = prefs[:section_order] if prefs[:section_order]
end
end
def build_dashboard_sections
all_sections = [
{
key: "cashflow_sankey",
title: "pages.dashboard.cashflow_sankey.title",
partial: "pages/dashboard/cashflow_sankey",
locals: { sankey_data: @cashflow_sankey_data, period: @period },
visible: @accounts.any?,
collapsible: true
},
{
key: "outflows_donut",
title: "pages.dashboard.outflows_donut.title",
partial: "pages/dashboard/outflows_donut",
locals: { outflows_data: @outflows_data, period: @period },
visible: @accounts.any? && @outflows_data[:categories].present?,
collapsible: true
},
{
key: "investment_summary",
title: "pages.dashboard.investment_summary.title",
partial: "pages/dashboard/investment_summary",
locals: { investment_statement: @investment_statement, period: @period },
visible: @accounts.any? && @investment_statement.investment_accounts.any?,
collapsible: true
},
{
key: "net_worth_chart",
title: "pages.dashboard.net_worth_chart.title",
partial: "pages/dashboard/net_worth_chart",
locals: { balance_sheet: @balance_sheet, period: @period },
visible: @accounts.any?,
collapsible: true
},
{
key: "balance_sheet",
title: "pages.dashboard.balance_sheet.title",
partial: "pages/dashboard/balance_sheet",
locals: { balance_sheet: @balance_sheet },
visible: @accounts.any?,
collapsible: true
}
]
# Order sections according to user preference
section_order = Current.user.dashboard_section_order
ordered_sections = section_order.map do |key|
all_sections.find { |s| s[:key] == key }
end.compact
# Add any new sections that aren't in the saved order (future-proofing)
all_sections.each do |section|
ordered_sections << section unless ordered_sections.include?(section)
end
ordered_sections
end
def github_provider
Provider::Registry.get_provider(:github)
end
def build_cashflow_sankey_data(net_totals, income_totals, expense_totals, currency)
nodes = []
links = []
node_indices = {}
add_node = ->(unique_key, display_name, value, percentage, color) {
node_indices[unique_key] ||= begin
nodes << { id: unique_key, name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }
nodes.size - 1
end
}
total_income = net_totals.total_net_income.to_f.round(2)
total_expense = net_totals.total_net_expense.to_f.round(2)
# Central Cash Flow node
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income, 100.0, "var(--color-success)")
# Build netted subcategory data from raw totals
net_subcategories_by_parent = build_net_subcategories(expense_totals, income_totals)
# Process net income categories (flow: subcategory -> parent -> cash_flow)
process_net_category_nodes(
categories: net_totals.net_income_categories,
total: total_income,
prefix: "income",
net_subcategories_by_parent: net_subcategories_by_parent,
add_node: add_node,
links: links,
cash_flow_idx: cash_flow_idx,
flow_direction: :inbound
)
# Process net expense categories (flow: cash_flow -> parent -> subcategory)
process_net_category_nodes(
categories: net_totals.net_expense_categories,
total: total_expense,
prefix: "expense",
net_subcategories_by_parent: net_subcategories_by_parent,
add_node: add_node,
links: links,
cash_flow_idx: cash_flow_idx,
flow_direction: :outbound
)
# Surplus/Deficit
net = (total_income - total_expense).round(2)
if net.positive?
percentage = total_income.zero? ? 0 : (net / total_income * 100).round(1)
idx = add_node.call("surplus_node", "Surplus", net, percentage, "var(--color-success)")
links << { source: cash_flow_idx, target: idx, value: net, color: "var(--color-success)", percentage: percentage }
end
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency).symbol }
end
# Nets subcategory expense and income totals, grouped by parent_id.
# Returns { parent_id => [ { category:, total: net_amount }, ... ] }
# Only includes subcategories with positive net (same direction as parent).
def build_net_subcategories(expense_totals, income_totals)
expense_subs = expense_totals.category_totals
.select { |ct| ct.category.parent_id.present? }
.index_by { |ct| ct.category.id }
income_subs = income_totals.category_totals
.select { |ct| ct.category.parent_id.present? }
.index_by { |ct| ct.category.id }
all_sub_ids = (expense_subs.keys + income_subs.keys).uniq
result = {}
all_sub_ids.each do |sub_id|
exp_ct = expense_subs[sub_id]
inc_ct = income_subs[sub_id]
exp_total = exp_ct&.total || 0
inc_total = inc_ct&.total || 0
net = exp_total - inc_total
category = exp_ct&.category || inc_ct&.category
next if net.zero?
parent_id = category.parent_id
result[parent_id] ||= []
result[parent_id] << { category: category, total: net.abs, net_direction: net > 0 ? :expense : :income }
end
result
end
# Builds sankey nodes/links for net categories with subcategory hierarchy.
# Subcategories matching the parent's flow direction are shown as children.
# Subcategories with opposite net direction appear on the OTHER side of the
# sankey (handled when the other side calls this method).
#
# flow_direction: :inbound (subcategory -> parent -> cash_flow) for income
# :outbound (cash_flow -> parent -> subcategory) for expenses
def process_net_category_nodes(categories:, total:, prefix:, net_subcategories_by_parent:, add_node:, links:, cash_flow_idx:, flow_direction:)
matching_direction = flow_direction == :inbound ? :income : :expense
categories.each do |ct|
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 || Category::UNCATEGORIZED_COLOR
node_key = "#{prefix}_#{ct.category.id || ct.category.name}"
all_subs = ct.category.id ? (net_subcategories_by_parent[ct.category.id] || []) : []
same_side_subs = all_subs.select { |s| s[:net_direction] == matching_direction }
# Also check if any subcategory has opposite direction — those will be
# rendered by the OTHER side's call to this method, linked to cash_flow
# directly (they appear as independent nodes on the opposite side).
opposite_subs = all_subs.select { |s| s[:net_direction] != matching_direction }
if same_side_subs.any?
parent_idx = add_node.call(node_key, ct.category.name, val, percentage, color)
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
same_side_subs.each do |sub|
sub_val = sub[:total].to_f.round(2)
sub_pct = val.zero? ? 0 : (sub_val / val * 100).round(1)
sub_color = sub[:category].color.presence || color
sub_key = "#{prefix}_sub_#{sub[:category].id}"
sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color)
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
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
# Render opposite-direction subcategories as standalone nodes on this side,
# linked directly to cash_flow. They represent subcategory surplus/deficit
# that goes against the parent's overall direction.
opposite_prefix = flow_direction == :inbound ? "expense" : "income"
opposite_subs.each do |sub|
sub_val = sub[:total].to_f.round(2)
sub_pct = total.zero? ? 0 : (sub_val / total * 100).round(1)
sub_color = sub[:category].color.presence || color
sub_key = "#{opposite_prefix}_sub_#{sub[:category].id}"
sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color)
# Opposite direction: if parent is outbound (expense), this sub is inbound (income)
if flow_direction == :inbound
links << { source: cash_flow_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct }
else
links << { source: sub_idx, target: cash_flow_idx, value: sub_val, color: sub_color, percentage: sub_pct }
end
end
end
end
def build_outflows_donut_data(net_totals)
currency_symbol = Money::Currency.new(net_totals.currency).symbol
total = net_totals.total_net_expense
categories = net_totals.net_expense_categories
.reject { |ct| 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),
currency: ct.currency,
percentage: ct.weight.round(1),
color: ct.category.color.presence || Category::UNCATEGORIZED_COLOR,
icon: ct.category.lucide_icon,
clickable: !ct.category.other_investments?
}
end
{ categories: categories, total: total.to_f.round(2), currency: net_totals.currency, currency_symbol: currency_symbol }
end
def ensure_intro_guest!
return if Current.user&.guest?
redirect_to root_path, alert: t("pages.intro.not_authorized", default: "Intro is only available to guest users.")
end
end