mirror of
https://github.com/we-promise/sure.git
synced 2026-08-04 16:12:14 +00:00
* fix(insights): correct the budget card's figure, badge noise and toast a11y
Four defects found while reviewing the insights surfaces for hierarchy.
**The budget_at_risk card's focal figure argued against its own headline.**
`insight_key_figure` returned `budget_spent_pct` for both budget cards, so
"2 categories need attention in your budget" displayed "14% / of budget" —
a reassuring number as the visual focus of a warning. It now leads with the
flagged count ("2 / need attention"); budget_on_track keeps the percentage,
where overall consumption genuinely is the subject.
**The "New" pill carried no information.** Visiting /insights marks every
insight read in one `update_all`, so at first paint the pill was on every
row. On the page it becomes a dot — same signal, without an uppercase
tracked chip stealing weight from the title beside it. In the dashboard
widget it goes entirely: the well's header already counts unread ("New · 3")
and, with three rows, the pill was usually on all of them.
**The undo toast was silent to screen readers.** A card leaves the page via
a Turbo `remove`, which announces nothing, and the toast that explains it
had no live region — unlike its neighbour `_sync_toast`, which sets
`role="status" aria-live="polite"`.
**The undo toast could only be closed with a mouse.** Its close affordance
was a bare `icon "x"` with a click action: not focusable, not named. Now a
real `DS::Button`, matching `_sync_toast`.
The controller test asserting a per-row badge is updated to assert the
header count that replaces it, and to lock in the pill's removal.
* feat(insights): acknowledge instead of dismiss, on both surfaces (#2800)
Two complaints about the insight feed: the close (×) control felt wrong,
and clearing an insight was only possible on /insights — not on the
dashboard widget, which is the surface people actually look at.
**The × was lying.** Dismissal has never been permanent. GenerateInsightsJob
resurfaces a row whose bucketed metadata changes materially "even if the user
had read or dismissed the stale version" (its own comment), and 6 of 8
generators scope dedup_key to a month token, so dismissing July's budget card
says nothing about August's. A destructive-looking control was performing a
non-destructive act. It is now "Got it", and the contract is statable:
acknowledgement covers the numbers you saw; new numbers are a new insight.
No migration. The DB value stays "dismissed" and dismissed_at keeps its name;
only the enum key and the vocabulary the code speaks change, so existing rows
stay hidden and become undoable under an honest label.
**The action pyramid was inverted.** The escape hatch was a chromed icon
button in the card's top-right — the strongest secondary scan position — while
the card's actual purpose ("View budget") was a borderless ghost link under
the body text. Both now sit in a footer strip: the subject action gets the
chrome, acknowledging is quiet labelled text beside it, and the key figure
gets the corner to itself instead of competing with a control.
**The widget can clear its own rows.** Each row gains an acknowledge control,
revealed on pointer hover, on keyboard focus, and shown unconditionally on
touch where there is no hover. No gesture, so the section's drag-to-reorder
handlers are untouched. The row becomes a stretched link plus a sibling
button, because button_to renders a <form> and a form cannot nest in an <a>.
The group is named (group/insight). The dashboard <section> is itself a
`.group` for its header controls, and a bare group-hover: matches any ancestor
group — hovering one row, or the section header, revealed every row's control.
Acknowledging re-renders the well rather than removing a row, so the next
insight is promoted into the freed slot; Insight::FEED_LIMIT is now shared
between the two controllers that render it so they cannot drift. Undo restores
the row on both surfaces, and carries autofocus so it is one keystroke away
after the acknowledged card leaves the DOM.
* fix(insights): guard unacknowledge! against non-acknowledged insights
CodeRabbit, Major: an arbitrary/stale PATCH /unacknowledge (e.g. an old
undo-toast link clicked after GenerateInsightsJob has since expired or
resurrected the insight) could force it back to :read regardless of
its actual current state — including pulling an :expired insight back
into visible view.
Guards the transition to only reverse an actual acknowledgement, per
CodeRabbit's suggested fix.
* test(insights): fix stale dismiss_insight_url route from main merge
main's preview-gate test used the pre-rename dismiss/undismiss route names;
this branch renamed those to acknowledge/unacknowledge earlier.
498 lines
20 KiB
Ruby
498 lines
20 KiB
Ruby
class PagesController < ApplicationController
|
|
include Periodable
|
|
|
|
# Per-widget dashboard layout guardrails. Deterministic defaults the masonry
|
|
# packer reads; users may override a grow widget's height via presets.
|
|
# col_span: "single" | "full" (full spans both columns in 2-col mode)
|
|
# grow: true for charts that should fill an allotted height,
|
|
# false for content-sized widgets (tables, stat grids)
|
|
# min_height: floor in px
|
|
DASHBOARD_SECTION_LAYOUTS = {
|
|
# Width-toggleable but full by default: the feed is much shorter than any
|
|
# other single-width widget, so defaulting to half leaves a grid hole the
|
|
# masonry can't backfill (dense placement needs a later card short enough
|
|
# to fit beside it, and none is). Users who pair it manually can go half.
|
|
"insights_feed" => { col_span: "full", grow: false, min_height: 0, width_toggle: true },
|
|
"cashflow_sankey" => { col_span: "full", grow: false, min_height: 384, width_toggle: true },
|
|
"money_flow" => { col_span: "single", grow: false, min_height: 0, width_toggle: true },
|
|
"outflows_donut" => { col_span: "single", grow: false, min_height: 0 },
|
|
"investment_summary" => { col_span: "single", grow: false, min_height: 0, width_toggle: true },
|
|
"net_worth_chart" => { col_span: "single", grow: true, min_height: 208, width_toggle: true },
|
|
"balance_sheet" => { col_span: "single", grow: false, min_height: 0, width_toggle: true }
|
|
}.freeze
|
|
|
|
# Number of consecutive months (ending at the selected month) shown as
|
|
# bars in the "money_flow" dashboard widget.
|
|
MONEY_FLOW_CHART_MONTHS = 6
|
|
|
|
# Selectable height presets (px) for grow widgets.
|
|
DASHBOARD_HEIGHT_PRESETS = { "compact" => 208, "auto" => 288, "tall" => 416 }.freeze
|
|
DEFAULT_HEIGHT_PRESET = "auto"
|
|
|
|
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)
|
|
# Preview-gated: skip the query outright rather than loading rows the
|
|
# section won't be built from.
|
|
@feed_insights = preview_features_enabled? ? Current.family.insights.visible.ordered.limit(Insight::FEED_LIMIT) : Insight.none
|
|
|
|
@money_flow_accounts = income_statement.eligible_accounts
|
|
@money_flow_month = money_flow_month_param
|
|
@money_flow_account_ids = money_flow_account_ids_param
|
|
@money_flow_data = build_money_flow_data(income_statement, @money_flow_month, @money_flow_account_ids)
|
|
|
|
@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: t("pages.release_notes_unavailable.name"),
|
|
published_at: Date.current,
|
|
body: t("pages.release_notes_unavailable.body_html")
|
|
}
|
|
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].respond_to?(:to_unsafe_h)
|
|
permitted["section_order"] = prefs[:section_order] if prefs[:section_order].is_a?(Array)
|
|
permitted["dashboard_section_layout"] = prefs[:dashboard_section_layout].to_unsafe_h if prefs[:dashboard_section_layout].respond_to?(:to_unsafe_h)
|
|
end
|
|
end
|
|
|
|
# Preview-gated, and omitted from the section list entirely rather than
|
|
# left in it with `visible: false`. Dropping it here means the two
|
|
# downstream behaviors fall out for free: the saved-order lookup finds
|
|
# nothing to map, and the insights_feed unshift special-case never fires.
|
|
def insights_feed_section
|
|
return nil unless preview_features_enabled?
|
|
|
|
{
|
|
key: "insights_feed",
|
|
title: "pages.dashboard.insights_feed.title",
|
|
partial: "pages/dashboard/insights_feed",
|
|
layout: section_layout("insights_feed"),
|
|
locals: { insights: @feed_insights },
|
|
visible: @feed_insights.any?,
|
|
collapsible: true
|
|
}
|
|
end
|
|
|
|
def build_dashboard_sections
|
|
all_sections = [
|
|
insights_feed_section,
|
|
{
|
|
key: "cashflow_sankey",
|
|
title: "pages.dashboard.cashflow_sankey.title",
|
|
partial: "pages/dashboard/cashflow_sankey",
|
|
layout: section_layout("cashflow_sankey"),
|
|
locals: { sankey_data: @cashflow_sankey_data, period: @period },
|
|
visible: @accounts.any?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "money_flow",
|
|
title: "pages.dashboard.money_flow.title",
|
|
partial: "pages/dashboard/money_flow",
|
|
layout: section_layout("money_flow"),
|
|
locals: { money_flow_data: @money_flow_data, accounts: @money_flow_accounts, col_span: section_layout("money_flow")[:col_span] },
|
|
visible: @accounts.any?,
|
|
collapsible: true
|
|
},
|
|
{
|
|
key: "outflows_donut",
|
|
title: "pages.dashboard.outflows_donut.title",
|
|
partial: "pages/dashboard/outflows_donut",
|
|
layout: section_layout("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",
|
|
layout: section_layout("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",
|
|
layout: section_layout("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",
|
|
layout: section_layout("balance_sheet"),
|
|
locals: { balance_sheet: @balance_sheet },
|
|
visible: @accounts.any?,
|
|
collapsible: true
|
|
}
|
|
].compact
|
|
|
|
# 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).
|
|
# The insights feed leads instead of appending: it's a proactive surface,
|
|
# and appending would bury it below the fold for every family with a
|
|
# saved order. Users can still drag it back down — that choice persists.
|
|
all_sections.each do |section|
|
|
next if ordered_sections.include?(section)
|
|
|
|
if section[:key] == "insights_feed"
|
|
ordered_sections.unshift(section)
|
|
else
|
|
ordered_sections << section
|
|
end
|
|
end
|
|
|
|
ordered_sections
|
|
end
|
|
|
|
# Resolves a section's layout guardrails, applying the user's height preset
|
|
# override (falling back to the deterministic default) for grow widgets.
|
|
def section_layout(key)
|
|
base = DASHBOARD_SECTION_LAYOUTS.fetch(key, { col_span: "single", grow: false, min_height: 0, width_toggle: false })
|
|
preset = Current.user.dashboard_section_height(key)
|
|
preset = DEFAULT_HEIGHT_PRESET unless DASHBOARD_HEIGHT_PRESETS.key?(preset)
|
|
|
|
col_span = base[:col_span]
|
|
if base[:width_toggle]
|
|
user_span = Current.user.dashboard_section_width(key)
|
|
col_span = user_span if %w[single full].include?(user_span)
|
|
end
|
|
|
|
base.merge(col_span: col_span, height_preset: preset, height_px: DASHBOARD_HEIGHT_PRESETS.fetch(preset))
|
|
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 money_flow_month_param
|
|
current_month = Date.current.beginning_of_month
|
|
month = Date.strptime(params[:money_flow_month], "%Y-%m-%d").beginning_of_month
|
|
# Clamp future months: build_money_flow_data caps each bar's end_date at
|
|
# Date.current, which would otherwise be earlier than a future month's
|
|
# start_date and blow up Period.custom's date-range validation.
|
|
month > current_month ? current_month : month
|
|
rescue ArgumentError, TypeError
|
|
current_month
|
|
end
|
|
|
|
# nil means "all accessible accounts" (the widget's default, unfiltered state)
|
|
def money_flow_account_ids_param
|
|
ids = Array(params[:money_flow_account_ids]).reject(&:blank?)
|
|
eligible_ids = @money_flow_accounts.map { |a| a.id.to_s }
|
|
ids &= eligible_ids
|
|
ids.presence
|
|
end
|
|
|
|
def build_money_flow_data(income_statement, selected_month, account_ids)
|
|
months = (MONEY_FLOW_CHART_MONTHS - 1).downto(0).map { |i| selected_month - i.months }
|
|
|
|
selected_period = nil
|
|
selected_totals = nil
|
|
|
|
bars = months.map do |month_start|
|
|
# Cap at today so an in-progress month (most commonly the current one)
|
|
# doesn't report totals for its not-yet-arrived days.
|
|
end_date = [ month_start.end_of_month, Date.current ].min
|
|
period = Period.custom(start_date: month_start, end_date: end_date)
|
|
totals = income_statement.totals_for(period, account_ids: account_ids)
|
|
|
|
if month_start == selected_month
|
|
selected_period = period
|
|
selected_totals = totals
|
|
end
|
|
|
|
{
|
|
date: month_start,
|
|
label: I18n.l(month_start, format: :short_month_year),
|
|
income: totals.income_money.amount.to_f.round(2),
|
|
expense: totals.expense_money.amount.to_f.round(2),
|
|
highlighted: month_start == selected_month,
|
|
partial: end_date < month_start.end_of_month
|
|
}
|
|
end
|
|
|
|
{
|
|
bars: bars,
|
|
period: selected_period,
|
|
month: selected_month,
|
|
income: selected_totals.income_money,
|
|
expense: selected_totals.expense_money,
|
|
balance: selected_totals.income_money - selected_totals.expense_money,
|
|
account_ids: account_ids
|
|
}
|
|
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
|