mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 21:14:56 +00:00
* 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>
345 lines
13 KiB
Ruby
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
|