Files
sure/app/views/layouts/application.html.erb
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

241 lines
12 KiB
Plaintext

<% intro_mode = Current.user&.ui_layout_intro? %>
<% home_path = intro_mode ? chats_path : root_path %>
<% mobile_nav_items = if intro_mode
[
{ name: t(".nav.home"), path: chats_path, icon: "home", icon_custom: false, active: page_active?(chats_path) },
{ name: "Intro", path: intro_path, icon: "sparkles", icon_custom: false, active: page_active?(intro_path) }
]
else
[
{ name: t(".nav.home"), path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) },
{ name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
{ name: t(".nav.reports"), path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) },
{ name: t(".nav.budgets"), path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) },
preview_gated_nav_item({ name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) }),
{ name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
].compact
end %>
<% desktop_nav_items = mobile_nav_items.reject { |item| item[:mobile_only] } %>
<% expanded_sidebar_class = "w-full" %>
<% collapsed_sidebar_class = "w-0 overflow-hidden" %>
<%= render "layouts/shared/htmldoc" do %>
<div
class="flex flex-col lg:flex-row h-full bg-surface"
data-controller="app-layout privacy-mode sidebar-resize"
data-app-layout-expanded-sidebar-class="<%= expanded_sidebar_class %>"
data-app-layout-collapsed-sidebar-class="<%= collapsed_sidebar_class %>"
data-app-layout-user-id-value="<%= Current.user.id %>">
<%= link_to t("layouts.application.skip_to_main"), "#main",
class: "sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:px-3 focus:py-2 focus:rounded-lg focus:bg-container focus:text-primary focus:shadow-border-xs" %>
<div
class="hidden fixed inset-0 bg-surface z-20 h-full w-full pt-[calc(env(safe-area-inset-top)+0.75rem)] pr-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] pl-3 overflow-y-auto transition-all duration-300"
data-app-layout-target="mobileSidebar">
<% unless intro_mode %>
<div class="mb-2">
<%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
</div>
<% end %>
<%= render(
"accounts/account_sidebar_tabs",
family: Current.family,
active_tab: @account_group_tab,
mobile: true
) %>
</div>
<%# MOBILE - Top nav %>
<nav class="lg:hidden flex justify-between items-center p-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
<% if intro_mode %>
<% else %>
<%= icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
<% end %>
<%= link_to home_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
<div class="flex items-center gap-1">
<button type="button"
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors focus-ring"
data-action="click->privacy-mode#toggle"
data-privacy-mode-target="toggle"
title="<%= t("layouts.application.privacy_mode") %>"
aria-label="<%= t("layouts.application.privacy_mode") %>"
aria-pressed="false">
<%= icon("eye-off", size: "sm", data: { privacy_mode_target: "iconOff" }) %>
<%= icon("eye", size: "sm", class: "hidden", data: { privacy_mode_target: "iconOn" }) %>
</button>
<%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12, intro_mode: intro_mode %>
</div>
</nav>
<%# DESKTOP - Left navbar %>
<div class="hidden lg:block border-r border-divider">
<nav class="h-full flex flex-col shrink-0 w-[84px] py-4">
<div class="pl-2 mb-3">
<%= link_to home_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
</div>
<ul class="space-y-0.5">
<% desktop_nav_items.reject { |item| item[:mobile_only] }.each do |nav_item| %>
<li>
<%= render "layouts/shared/nav_item", **nav_item %>
</li>
<% end %>
</ul>
<div class="pl-2 mt-auto mx-auto flex flex-col gap-2">
<%= render DS::Link.new(
variant: "icon",
icon: "message-circle-question",
href: "https://discord.gg/36ZGBsxYEK",
target: "_blank"
) %>
<%= render "users/user_menu", user: Current.user, intro_mode: intro_mode %>
</div>
</nav>
</div>
<%# DESKTOP - Left sidebar %>
<%= tag.div class: class_names(
"hidden lg:block relative py-4 shrink-0 transition-all duration-300 border-divider",
Current.user.show_sidebar? ? [expanded_sidebar_class, "border-r"] : [collapsed_sidebar_class, "border-r-0"],
),
style: "max-width: var(--left-sidebar-width, 320px)",
inert: !Current.user.show_sidebar?,
data: { app_layout_target: "leftSidebar", sidebar_resize_target: "leftSidebar" } do %>
<%= render "layouts/shared/sidebar_resize_handle", side: "left" %>
<div class="px-4 h-full flex flex-col overflow-y-auto">
<% if content_for?(:sidebar) %>
<%= yield :sidebar %>
<% else %>
<div class="grow">
<%= render "accounts/account_sidebar_tabs", family: Current.family, active_tab: @account_group_tab %>
</div>
<% if Current.family.trialing? && !self_hosted? %>
<div class="px-4 py-3 space-y-4 bg-container shadow-border-xs rounded-xl">
<div class="flex items-start justify-between">
<div>
<p class="text-sm font-medium text-primary"><%= t("layouts.trial.open_demo") %></p>
<p class="text-sm text-secondary"><%= t("layouts.trial.data_deleted_in_days", days: Current.family.days_left_in_trial) %></p>
</div>
<%= render DS::Link.new(
text: t("layouts.trial.contribute"),
href: upgrade_subscription_path,
) %>
</div>
<div class="flex items-center gap-0.5 h-1.5">
<div class="h-full bg-warning rounded-full" style="width: <%= Current.family.percentage_of_trial_completed %>%"></div>
<div class="h-full bg-surface-inset rounded-full" style="width: <%= Current.family.percentage_of_trial_remaining %>%"></div>
</div>
</div>
<% end %>
<% end %>
</div>
<% end %>
<%# SHARED - Main content %>
<%= tag.main id: "main", class: class_names("grow overflow-y-auto px-3 lg:px-10 w-full mx-auto pb-[calc(5rem+env(safe-area-inset-bottom))] lg:pb-0"), data: { app_layout_target: "content", viewport_target: "content" } do %>
<% unless intro_mode %>
<div class="hidden lg:flex gap-2 items-center justify-between mb-6 sticky top-0 z-10 -mx-3 lg:-mx-10 px-3 lg:px-10 py-4 bg-surface border-b border-tertiary">
<div class="flex items-center gap-2">
<%= icon("panel-left", as_button: true, data: { action: "app-layout#toggleLeftSidebar" }) %>
<% if content_for?(:breadcrumbs) %>
<%= yield :breadcrumbs %>
<% else %>
<%= render "layouts/shared/breadcrumbs", breadcrumbs: breadcrumbs %>
<% end %>
</div>
<div class="flex items-center gap-2">
<%# Preview-gated. Gating the whole block (rather than just the
link) also keeps the unread COUNT off every page render for
users who haven't opted in. No preview dot on the icon: the
unread badge already occupies that corner. %>
<% if Current.family && preview_features_enabled? %>
<% unread_insights_count = Current.family.insights.active.count %>
<%# Prefetch disabled: /insights marks insights read on real visits
and deliberately skips prefetch requests, so a prefetched
response served on click would never clear the badge. %>
<%= link_to insights_path,
class: "relative inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors focus-ring",
title: t("layouts.application.insights"),
aria: { label: t("layouts.application.insights") },
data: { turbo_prefetch: false } do %>
<%= icon("lightbulb", size: "sm") %>
<% if unread_insights_count.positive? %>
<span class="absolute top-1 right-1 min-w-4 h-4 px-1 rounded-full bg-inverse text-inverse text-[10px] font-medium flex items-center justify-center pointer-events-none"><%= unread_insights_count > 9 ? "9+" : unread_insights_count %></span>
<% end %>
<% end %>
<% end %>
<button type="button"
class="inline-flex items-center justify-center w-9 h-9 pointer-coarse:w-11 pointer-coarse:h-11 rounded-lg text-secondary hover:text-primary hover:bg-container-inset-hover transition-colors focus-ring"
data-action="click->privacy-mode#toggle"
data-privacy-mode-target="toggle"
title="<%= t("layouts.application.privacy_mode") %>"
aria-label="<%= t("layouts.application.privacy_mode") %>"
aria-pressed="false">
<%= icon("eye-off", size: "sm", data: { privacy_mode_target: "iconOff" }) %>
<%= icon("eye", size: "sm", class: "hidden", data: { privacy_mode_target: "iconOn" }) %>
</button>
<%= icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %>
</div>
</div>
<% end %>
<% if content_for?(:page_header) %>
<%= yield :page_header %>
<% end %>
<%= yield %>
<% end %>
<%# DESKTOP - Right sidebar %>
<%= tag.div class: class_names(
"hidden lg:block relative h-full shrink-0 transition-all duration-300 border-divider",
Current.user.show_ai_sidebar? ? [expanded_sidebar_class, "border-l"] : [collapsed_sidebar_class, "border-l-0"],
),
style: "max-width: var(--right-sidebar-width, 400px)",
inert: !Current.user.show_ai_sidebar?,
data: { app_layout_target: "rightSidebar", sidebar_resize_target: "rightSidebar" } do %>
<%= render "layouts/shared/sidebar_resize_handle", side: "right" %>
<%= tag.div id: "chat-container", class: "relative h-full px-4 overflow-y-auto", data: { controller: "chat hotkey", turbo_permanent: true } do %>
<div class="flex flex-col h-full justify-between shrink-0">
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
<div class="flex justify-center items-center h-full">
<%= icon("loader-circle", class: "animate-spin") %>
</div>
<% end %>
</div>
<% unless Current.user.ai_enabled? %>
<div class="absolute backdrop-blur-lg inset-0 h-full w-full flex flex-col justify-center items-center px-4">
<%= render "chats/ai_consent" %>
</div>
<% end %>
<% end %>
<% end %>
<%# MOBILE - Bottom Nav %>
<%= tag.nav class: "lg:hidden fixed bottom-0 left-0 right-0 bg-surface z-10 border-t border-tertiary flex justify-around pb-[env(safe-area-inset-bottom)]",
data: { viewport_target: "bottomNav" } do %>
<% mobile_nav_items.each do |nav_item| %>
<%= render "layouts/shared/nav_item", **nav_item %>
<% end %>
<% end %>
</div>
<% end %>