mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +00:00
* 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>
165 lines
9.0 KiB
Markdown
165 lines
9.0 KiB
Markdown
# Gating a preview feature
|
|
|
|
Sure ships preview features behind a single per-user toggle. Users opt in via Settings → Preferences. Opted-in users see your feature; everyone else doesn't. This guide is for hooking a new feature into the gate.
|
|
|
|
The intent is to ship in-progress work without blocking smaller PRs on a "feels finished" bar. You gate the entry points (routes, nav, anything that links into your feature) and iterate behind them. Once stable, you remove the gate in a small follow-up PR.
|
|
|
|
## How the gate works
|
|
|
|
The state lives on `users.preferences["preview_features_enabled"]`, a key inside the existing JSONB column. It defaults to `false`. Reading it goes through `User#preview_features_enabled?`.
|
|
|
|
`ApplicationController` includes the `PreviewGateable` concern, which exposes two methods to every controller:
|
|
|
|
- `preview_features_enabled?`. Returns a boolean. `false` for logged-out callers.
|
|
- `require_preview_features!`. A `before_action` helper. Redirects users without preview access to `/` with a flash that points them at Settings → Preferences.
|
|
|
|
The concern also registers `preview_features_enabled?` as a helper method, so views can call it directly.
|
|
|
|
Key files:
|
|
|
|
- `app/controllers/concerns/preview_gateable.rb`. The concern.
|
|
- `app/models/user.rb`. The `preview_features_enabled?` predicate.
|
|
- `app/views/settings/preferences/show.html.erb`. The toggle UI users see.
|
|
- `app/components/DS/pill.rb`. The `Preview` / `Canary` marker pill.
|
|
- `config/locales/views/preview/en.yml`. The redirect flash copy.
|
|
|
|
## Gating a controller
|
|
|
|
Add `require_preview_features!` as a `before_action`. That's it.
|
|
|
|
```ruby
|
|
class GoalsController < ApplicationController
|
|
before_action :require_preview_features!
|
|
end
|
|
```
|
|
|
|
Routes stay defined; the gate runs per-request. Users without preview access hitting `/goals` get redirected with a flash. Preview users pass through.
|
|
|
|
If only some actions are gated, scope the `before_action`:
|
|
|
|
```ruby
|
|
class TransactionsController < ApplicationController
|
|
before_action :require_preview_features!, only: %i[forecast scenarios]
|
|
end
|
|
```
|
|
|
|
## Gating a view
|
|
|
|
Wrap the relevant fragment in the helper:
|
|
|
|
```erb
|
|
<% if preview_features_enabled? %>
|
|
<li>
|
|
<%= link_to t(".nav.goals"), goals_path %>
|
|
</li>
|
|
<% end %>
|
|
```
|
|
|
|
Same pattern works for dashboard widgets, scoreboard cards, anything that surfaces preview data alongside non-preview data. The helper resolves on every request and reflects the current user's preference.
|
|
|
|
## Gating the main nav
|
|
|
|
The desktop sidebar rail and the mobile bottom nav both render from `app/views/layouts/shared/_nav_item.html.erb`. The partial accepts an optional `preview:` local — when true, it overlays a violet dot-only pill on the icon so opted-in users can tell at a glance that the rail entry leads to a preview surface.
|
|
|
|
Use the `preview_gated_nav_item` helper to wrap the entry. It returns `nil` for users without preview access (so the entry never enters the nav, once `Array#compact` runs) and stamps `preview: true` for opted-in users (so the partial paints the dot). One call, both halves of the gate:
|
|
|
|
```erb
|
|
<% mobile_nav_items = [
|
|
{ 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) },
|
|
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 %>
|
|
```
|
|
|
|
You don't need to touch `_nav_item.html.erb` or set `preview: true` by hand. Adding a new preview nav entry is one helper call wrapped around the same hash you'd write anyway.
|
|
|
|
## Marking the feature in the UI
|
|
|
|
When a preview surface renders for an opted-in user, mark it. The pill component lives in the design system:
|
|
|
|
```erb
|
|
<%# Next to a page header. The md size pairs with h1 / h2. %>
|
|
<%= render DS::Pill.new(label: "Preview", size: :md) %>
|
|
|
|
<%# Next to a sidebar nav label or section title. sm is the default. %>
|
|
<%= render DS::Pill.new(label: "Preview") %>
|
|
|
|
<%# Same shape, fuchsia tone, for canary / experimental surfaces. %>
|
|
<%= render DS::Pill.new(label: "Canary", tone: :fuchsia) %>
|
|
|
|
<%# Sidebar icon rail has no room for a label. The dot-only mode keeps the tone semantics without the text. %>
|
|
<%= render DS::Pill.new(tone: :violet, dot_only: true, title: "Preview") %>
|
|
```
|
|
|
|
Default tone is violet. Tones available: `violet`, `indigo`, `fuchsia`, `amber`, `gray`. Styles: `soft` (default), `filled`, `outline`. Sizes: `sm` (default), `md`. The Lookbook preview at `/design-system` (look for `PillComponentPreview#default`) flips every option, so you can see what your call site renders without a round trip to Rails.
|
|
|
|
## Tests
|
|
|
|
Gated controllers should test both states. The pattern:
|
|
|
|
```ruby
|
|
class GoalsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
sign_in @user = users(:family_admin)
|
|
end
|
|
|
|
test "redirects users without preview access" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
|
|
|
|
get goals_url
|
|
|
|
assert_redirected_to root_path
|
|
assert_match(/preview/i, flash[:alert])
|
|
end
|
|
|
|
test "renders for users with preview access" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
|
|
|
|
get goals_url
|
|
|
|
assert_response :success
|
|
end
|
|
end
|
|
```
|
|
|
|
If you write a system test, flip the preference in setup the same way before the visit.
|
|
|
|
## Removing the gate when the feature ships GA
|
|
|
|
When a feature moves from preview to general availability, removing the gate is a small mechanical PR:
|
|
|
|
1. Drop the `before_action :require_preview_features!` line from the controller.
|
|
2. Unwrap the `if preview_features_enabled?` blocks in views.
|
|
3. Drop the `DS::Pill` markers from headers and section titles, and unwrap the `preview_gated_nav_item(...)` call back into a plain nav-item hash.
|
|
4. Delete the controller / view tests that exercise the redirect.
|
|
|
|
Grep for `require_preview_features!` and `preview_features_enabled?` near your feature to confirm nothing's left behind.
|
|
|
|
## Notes
|
|
|
|
The flag is per-user, not per-family. Two users in the same family can see different versions of the product if one opts in and the other doesn't. That's intentional. Data is family-scoped, but visibility is a personal preference. If you write a feature that creates family-shared data (goals, budgets, etc.), the data persists when a user toggles preview off. The UI just disappears from their view while still showing up for opted-in family members.
|
|
|
|
The gate does nothing for background jobs on its own — `PreviewGateable` reads `Current.user`, which a Sidekiq worker doesn't have. A cron job runs regardless of who has preview enabled. That's usually correct: data should keep flowing, so it's there when someone opts in. `SweepExpiredGoalPledgesJob` is the typical shape — it only walks pledges that opted-in users created, so it's naturally inert for everyone else and needs no gate.
|
|
|
|
Gate the job when it does work that isn't free. Two cases: it sends something outward (notifications, emails — gate at the send site), or it *manufactures* data for every family rather than moving existing data around, burning compute or paid API calls per family. `GenerateInsightsJob` is the second case: seven generators over the income statement and balance sheet, plus optional LLM narration, for every family nightly.
|
|
|
|
For a family-scoped job, roll the per-user flag up to the family:
|
|
|
|
```ruby
|
|
# app/models/family.rb
|
|
scope :with_preview_features, -> { where(id: User.with_preview_features.select(:family_id)) }
|
|
|
|
def preview_features_enabled?
|
|
users.with_preview_features.exists?
|
|
end
|
|
```
|
|
|
|
Filter the fan-out with the scope (one indexed query — `User.with_preview_features` is a jsonb containment match against the GIN index on `users.preferences`, not a load-and-iterate), and re-check with the predicate inside the per-family path, which is reachable directly from controllers and the console. Keep the same predicate name on both models so the GA-removal grep finds every call site. `GenerateInsightsJob` is the reference implementation.
|
|
|
|
One opted-in member is enough to generate for the whole family — consistent with the per-user-visibility / family-scoped-data split described above.
|
|
|
|
Note what the shared name costs: `preview_features_enabled?` means "I opted in" on `User` but "somebody in my household opted in" on `Family`. Only ever gate background work on the family form. Gating UI on it (`Current.family.preview_features_enabled?` reads naturally, which is the trap) would show the feature to someone who explicitly opted out because a family member opted in. For anything a person sees, use the `PreviewGateable` helper, which reads `Current.user`.
|
|
|
|
The redirect target is `/`. If you want gated controllers to land somewhere else (a docs page, an opt-in nudge), override `require_preview_features!` in the controller, or write a thin custom `before_action` that calls `preview_features_enabled?` directly.
|