Files
sure/app/controllers/pages_controller.rb
Juan José Mata 375dd060dc feat(insights): gate the insights feed behind preview features (#2788)
* feat(insights): gate the insights feed behind preview features

Insights shipped to everyone in #2550. Make it opt-in via Settings →
Preferences until it's proven, so users who haven't enabled preview
features see nothing and cost nothing.

Entry points gated:
- InsightsController — require_preview_features! covers all four actions,
  including the refresh action that enqueues the job
- Dashboard — the insights_feed section is omitted from the section list
  rather than left in it hidden, so the saved-order lookup and the
  insights_feed unshift special-case never fire; the feed query is skipped
- Top bar — the lightbulb entry and its unread COUNT, which previously ran
  on every page render

The job is gated too, departing from the guide's default that background
jobs keep running. That default fits a job like SweepExpiredGoalPledgesJob,
which only walks records opted-in users created and is naturally inert.
GenerateInsightsJob instead manufactures data for every family nightly —
seven generators over the income statement and balance sheet, plus paid LLM
narration — so it would have kept spending on families who can't see the
result. The fan-out filters with Family.with_preview_features (one indexed
jsonb containment query, not load-and-iterate), and generate_for_family
re-checks above the advisory lock so a gated family skips the broadcast too.

Adds Family#preview_features_enabled? and the matching scopes, keeping the
predicate name identical on User and Family so the guide's GA-removal grep
finds every call site. Verified the SQL scope and the Ruby predicate agree
for true / false / "yes" / nil. Documents the job-gating pattern in
docs/llm-guides/gating-a-preview-feature.md, which previously said the gate
does nothing for jobs.

Existing insight rows are left alone: invisible without the flag, and the
next nightly run refreshes facts and expires anything stale if a family
opts in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4

* perf(insights): use EXISTS for the family preview rollup

Family#preview_features_enabled? is asked once per family by the nightly
job; the block form loaded and instantiated every member to answer a
boolean. Delegate to the scope instead.

The predicate now shares an implementation with the scope, so the
truthy-non-boolean test asserts against User#preview_features_enabled? —
the predicate the UI actually gates on — to keep the cross-check
meaningful rather than tautological.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4

* docs(insights): fix guide/code drift and stale cron description

Review follow-ups from @gariasf:

- The guide's family-rollup snippet still showed the block form after the
  EXISTS commit changed it. It mattered more than normal doc drift: the
  paragraph below calls GenerateInsightsJob "the reference implementation",
  so the next person writing a gated job would have copied the form
  family.rb's comment explicitly rejects.
- schedule.yml still described the job as running for "all families" — the
  string someone reads while debugging why a family got no insights.
- Document that the shared predicate name is per-user on User but
  "anyone in the household" on Family, and prohibit gating UI on the family
  form: Current.family.preview_features_enabled? reads naturally and would
  show the feature to a user who explicitly opted out. Noted in both the
  model and the guide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Guillem Arias Fauste <accounts@gariasf.com>
2026-07-26 07:18:23 +02:00

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(3) : 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