From 34ec12b44895121e36c2052dea5ed091139827a3 Mon Sep 17 00:00:00 2001 From: "Sure Admin (bot)" Date: Sun, 17 May 2026 19:59:46 +0200 Subject: [PATCH 01/28] fix(preview): replace PR preview cleanly on redeploy (#1819) --- .github/workflows/preview-deploy.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index eff9c847a..269adf510 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -82,6 +82,16 @@ jobs: sed -i "s/\${PR_NUMBER}/${{ github.event.pull_request.number }}/g" src/index.ts cat wrangler.toml + - name: Delete existing preview Worker before redeploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + working-directory: workers/preview + run: | + WORKER_NAME="sure-preview-${{ github.event.pull_request.number }}" + echo "Ensuring fresh preview deployment for $WORKER_NAME" + npx wrangler delete --name "$WORKER_NAME" --force || echo "Existing preview not found; continuing" + - name: Create GitHub Deployment id: deployment uses: actions/github-script@v7 From 4c7d3638fd908499ca32cc3b24fb24c8c3b3fa51 Mon Sep 17 00:00:00 2001 From: "Sure Admin (bot)" Date: Sun, 17 May 2026 20:24:45 +0200 Subject: [PATCH 02/28] fix(preview): delete stale container app before redeploy (#1820) --- .github/workflows/preview-deploy.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 269adf510..e23b9694c 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -82,6 +82,28 @@ jobs: sed -i "s/\${PR_NUMBER}/${{ github.event.pull_request.number }}/g" src/index.ts cat wrangler.toml + - name: Delete existing preview container app before redeploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + working-directory: workers/preview + run: | + CONTAINER_NAME="sure-preview-${{ github.event.pull_request.number }}-railscontainer" + echo "Looking for stale preview container app: $CONTAINER_NAME" + + CONTAINER_ID=$(npx wrangler containers list --json | jq -r --arg NAME "$CONTAINER_NAME" ' + map(select((.name // .application_name // .app_name // "") == $NAME)) + | first + | (.id // .container_id // .application_id // empty) + ') + + if [ -n "$CONTAINER_ID" ]; then + echo "Deleting stale preview container app $CONTAINER_NAME ($CONTAINER_ID)" + npx wrangler containers delete "$CONTAINER_ID" + else + echo "No stale preview container app found; continuing" + fi + - name: Delete existing preview Worker before redeploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} From c54bbaf03a4c3c4ca27516a7afa1ceed30ec9ebf Mon Sep 17 00:00:00 2001 From: "Sure Admin (bot)" Date: Sun, 17 May 2026 21:35:15 +0200 Subject: [PATCH 03/28] fix(preview): run preview container in development (#1821) --- workers/preview/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workers/preview/src/index.ts b/workers/preview/src/index.ts index 872cf1a99..14b2e7d1b 100644 --- a/workers/preview/src/index.ts +++ b/workers/preview/src/index.ts @@ -65,7 +65,7 @@ export class RailsContainer extends Container { pingEndpoint = "container/up"; entrypoint = ["/rails/bin/preview-entrypoint", "bundle", "exec", "puma", "-C", "config/puma.rb"]; envVars = { - RAILS_ENV: "production", + RAILS_ENV: "development", RAILS_LOG_TO_STDOUT: "true", RAILS_SERVE_STATIC_FILES: "true", SECRET_KEY_BASE: "preview-secret-key-base-for-pr-${PR_NUMBER}", From 87817213be3b0196021d771f6439578e88b3774f Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 15:52:14 +0200 Subject: [PATCH 04/28] =?UTF-8?q?ux(goals):=20fix=20"0=20of=20N=20=C2=B7?= =?UTF-8?q?=20N=20reached"=20KPI=20weirdness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When every active goal already hit its target, the "Goals on track" tile read "0 of 2 · 2 reached" — logically correct but emotionally upside-down. Reached goals aren't being tracked toward pace anymore; they belong in the trophy column, not in the fraction. - New `tracked_total` excludes reached and paused goals from the denominator. Paused stops the pace clock on purpose; reached has already cleared it. - When `tracked_total` hits zero and at least one goal is reached, the tile swaps to a celebratory empty state ("All caught up · N reached") instead of trying to render a fraction with no denominator. - Drop "reached" from the subline when the fraction is calculable. The fraction is a needle, "N reached" is a trophy — surfacing them together muddied the message. Reached only appears in the all-caught- up empty state from here on. Active-first / reached-last grid order already drops out of the existing ACTIVE_STATUS_RANK sort (reached defaults to the lowest rank so it naturally lands after behind / on_track / no_target_date / paused). --- app/controllers/goals_controller.rb | 10 +++++ app/views/goals/index.html.erb | 50 +++++++++++++++-------- config/locales/views/goals/en.yml | 1 + test/controllers/goals_controller_test.rb | 22 ++++++++++ 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index afba329b6..ae34762a0 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -219,6 +219,15 @@ class GoalsController < ApplicationController no_date = active_goals.count { |g| g.status == :no_target_date } paused = active_goals.count(&:paused?) + # Denominator of the "Goals on track" tile. Goals that hit their + # target are no longer being tracked toward pace, so they don't + # belong in the fraction; paused goals stop the pace clock on + # purpose, so they don't either. When this hits zero the tile + # swaps to a celebration / empty state in the view. + tracked_total = active_goals.count do |g| + !g.paused? && g.status != :reached + end + { currency: currency, velocity_30d_money: Money.new(velocity_30d.abs, currency), @@ -232,6 +241,7 @@ class GoalsController < ApplicationController behind_count: behind, no_date_count: no_date, paused_count: paused, + tracked_total: tracked_total, active_total: active_goals.size } end diff --git a/app/views/goals/index.html.erb b/app/views/goals/index.html.erb index 3411389bf..f9d8ffc04 100644 --- a/app/views/goals/index.html.erb +++ b/app/views/goals/index.html.erb @@ -45,23 +45,39 @@

<%= t(".kpi.on_track_label") %>

-

- <%= t(".kpi.on_track_value", on_track: @kpi[:on_track_count], total: @kpi[:active_total]) %> -

-

- <% - parts = [] - parts << t(".kpi.on_track_sub_parts.reached", count: @kpi[:reached_count]) if @kpi[:reached_count].positive? - parts << t(".kpi.on_track_sub_parts.behind", count: @kpi[:behind_count]) if @kpi[:behind_count].positive? - parts << t(".kpi.on_track_sub_parts.no_date", count: @kpi[:no_date_count]) if @kpi[:no_date_count].positive? - parts << t(".kpi.on_track_sub_parts.paused", count: @kpi[:paused_count]) if @kpi[:paused_count].positive? - %> - <% if parts.any? %> - <%= parts.join(" · ") %> - <% else %> - <%= t(".kpi.on_track_sub_all_good") %> - <% end %> -

+ <% if @kpi[:tracked_total].zero? && @kpi[:reached_count].positive? %> + <%# All active goals already hit their target — fraction would + read "0 of 0" or paper over success. Swap to a celebratory + empty state instead. %> +

+ <%= t(".kpi.on_track_all_caught_up") %> +

+

+ <%= t(".kpi.on_track_sub_parts.reached", count: @kpi[:reached_count]) %> +

+ <% else %> +

+ <%= t(".kpi.on_track_value", on_track: @kpi[:on_track_count], total: @kpi[:tracked_total]) %> +

+

+ <% + # Reached goals are intentionally absent from the subline when + # the fraction is calculable. They no longer count toward pace + # tracking, and surfacing "N reached" next to "X of Y" muddied + # the message (the fraction is a needle, "N reached" is a + # trophy. different signals). + parts = [] + parts << t(".kpi.on_track_sub_parts.behind", count: @kpi[:behind_count]) if @kpi[:behind_count].positive? + parts << t(".kpi.on_track_sub_parts.no_date", count: @kpi[:no_date_count]) if @kpi[:no_date_count].positive? + parts << t(".kpi.on_track_sub_parts.paused", count: @kpi[:paused_count]) if @kpi[:paused_count].positive? + %> + <% if parts.any? %> + <%= parts.join(" · ") %> + <% else %> + <%= t(".kpi.on_track_sub_all_good") %> + <% end %> +

+ <% end %>
diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml index 876727f33..8c7dc1de4 100644 --- a/config/locales/views/goals/en.yml +++ b/config/locales/views/goals/en.yml @@ -34,6 +34,7 @@ en: one: 1 paused other: "%{count} paused" on_track_sub_all_good: All active goals on pace + on_track_all_caught_up: All caught up goals_section: heading: Goals subtitle: Save toward what matters. diff --git a/test/controllers/goals_controller_test.rb b/test/controllers/goals_controller_test.rb index 7600d4fd9..b959e6eaf 100644 --- a/test/controllers/goals_controller_test.rb +++ b/test/controllers/goals_controller_test.rb @@ -151,6 +151,28 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to goals_path end + test "index KPI swaps to 'All caught up' when every tracked goal is reached" do + family = users(:family_admin).family + family.goals.destroy_all + build_goal(family, "Wedding", target_amount: 1, target_date: 1.year.from_now) + Goal.any_instance.stubs(:status).returns(:reached) + + get goals_url + assert_response :success + assert_match(/All caught up/i, response.body) + assert_match(/1\s*reached/i, response.body) + end + + private + def build_goal(family, name, target_amount: 1_000_000, target_date: nil) + g = family.goals.new(name: name, target_amount: target_amount, target_date: target_date, currency: "USD") + g.goal_accounts.build(account: @depository) + g.save! + g + end + + public + test "another family's goal returns 404" do other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") other_account = Account.create!(family: other_family, accountable: Depository.new, name: "Foreign", currency: "USD", balance: 100) From 5c7babc44e1e1e56c19ba421d923ea2f466dab68 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 20:13:44 +0200 Subject: [PATCH 05/28] feat(goals): gate Goals v2 behind beta features toggle Add require_beta_features! to GoalsController and GoalPledgesController, hide the Goals nav item for non-beta users, and tag index/show headers with the Beta pill marker. Update controller tests to enable the preference in setup and assert the redirect for users without access. --- app/controllers/goal_pledges_controller.rb | 1 + app/controllers/goals_controller.rb | 1 + app/views/goals/index.html.erb | 5 ++++- app/views/goals/show.html.erb | 5 ++++- app/views/layouts/application.html.erb | 4 ++-- test/controllers/goal_pledges_controller_test.rb | 13 ++++++++++++- test/controllers/goals_controller_test.rb | 13 ++++++++++++- 7 files changed, 36 insertions(+), 6 deletions(-) diff --git a/app/controllers/goal_pledges_controller.rb b/app/controllers/goal_pledges_controller.rb index c3c9c438d..d2fe99326 100644 --- a/app/controllers/goal_pledges_controller.rb +++ b/app/controllers/goal_pledges_controller.rb @@ -1,4 +1,5 @@ class GoalPledgesController < ApplicationController + before_action :require_beta_features! before_action :set_goal before_action :set_pledge, only: %i[renew destroy] rescue_from ActiveRecord::RecordNotFound, with: :record_not_found diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index ae34762a0..c37345a09 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -1,4 +1,5 @@ class GoalsController < ApplicationController + before_action :require_beta_features! before_action :set_goal, only: %i[show edit update destroy pause resume complete archive unarchive] rescue_from ActiveRecord::RecordNotFound, with: :goal_not_found diff --git a/app/views/goals/index.html.erb b/app/views/goals/index.html.erb index f9d8ffc04..eed6522ce 100644 --- a/app/views/goals/index.html.erb +++ b/app/views/goals/index.html.erb @@ -1,6 +1,9 @@
-

<%= t(".title") %>

+
+

<%= t(".title") %>

+ <%= render DS::Pill.new(label: "Beta", size: :md) %> +

<%= t(".subtitle") %>

diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb index 7036e1638..cbdf63cf9 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -4,7 +4,10 @@ <%= render Goals::AvatarComponent.new(goal: @goal, size: "xl") %>
-

<%= @goal.name %>

+
+

<%= @goal.name %>

+ <%= render DS::Pill.new(label: "Beta", size: :md) %> +

<%= @goal.header_summary %>

<% last_days = @goal.last_matched_pledge_days_ago %> <% unless last_days.nil? %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 16457cc86..b3244bb88 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -11,9 +11,9 @@ else { 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) }, - { name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) }, + (beta_features_enabled? ? { name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) } : nil), { 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] } %> diff --git a/test/controllers/goal_pledges_controller_test.rb b/test/controllers/goal_pledges_controller_test.rb index af9227ad5..274a37149 100644 --- a/test/controllers/goal_pledges_controller_test.rb +++ b/test/controllers/goal_pledges_controller_test.rb @@ -2,13 +2,24 @@ require "test_helper" class GoalPledgesControllerTest < ActionDispatch::IntegrationTest setup do - sign_in users(:family_admin) + @user = users(:family_admin) + @user.update!(preferences: (@user.preferences || {}).merge("beta_features_enabled" => true)) + sign_in @user @goal = goals(:vacation_italy) @account = accounts(:depository) @pledge = goal_pledges(:open_transfer) ensure_tailwind_build end + test "redirects users without beta access" do + @user.update!(preferences: (@user.preferences || {}).merge("beta_features_enabled" => false)) + + get new_goal_pledge_url(@goal), headers: { "Turbo-Frame" => "modal" } + + assert_redirected_to root_path + assert_match(/beta/i, flash[:alert]) + end + test "new renders the pledge form inside a turbo frame" do get new_goal_pledge_url(@goal), headers: { "Turbo-Frame" => "modal" } assert_response :success diff --git a/test/controllers/goals_controller_test.rb b/test/controllers/goals_controller_test.rb index b959e6eaf..4ae4f9c6b 100644 --- a/test/controllers/goals_controller_test.rb +++ b/test/controllers/goals_controller_test.rb @@ -2,13 +2,24 @@ require "test_helper" class GoalsControllerTest < ActionDispatch::IntegrationTest setup do - sign_in users(:family_admin) + @user = users(:family_admin) + @user.update!(preferences: (@user.preferences || {}).merge("beta_features_enabled" => true)) + sign_in @user @goal = goals(:vacation_italy) @depository = accounts(:depository) @connected = accounts(:connected) ensure_tailwind_build end + test "redirects users without beta access" do + @user.update!(preferences: (@user.preferences || {}).merge("beta_features_enabled" => false)) + + get goals_url + + assert_redirected_to root_path + assert_match(/beta/i, flash[:alert]) + end + test "index renders with active filter by default" do get goals_url assert_response :success From 347990438760cb9de571ecc71ad36a8e1c5baa85 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 20:21:23 +0200 Subject: [PATCH 06/28] feat(goals): add Beta dot marker on sidebar nav rail Pass beta: true on gated nav items so the nav_item partial renders a violet dot-only pill in the top-right of the icon. The doc covers the dot_only usage; the nav itself was never wired up before merge. --- app/views/layouts/application.html.erb | 2 +- app/views/layouts/shared/_nav_item.html.erb | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b3244bb88..73a6ced37 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -11,7 +11,7 @@ else { 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) }, - (beta_features_enabled? ? { name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) } : nil), + (beta_features_enabled? ? { name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path), beta: true } : nil), { name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true } ].compact end %> diff --git a/app/views/layouts/shared/_nav_item.html.erb b/app/views/layouts/shared/_nav_item.html.erb index 3c700e184..5738e82c0 100644 --- a/app/views/layouts/shared/_nav_item.html.erb +++ b/app/views/layouts/shared/_nav_item.html.erb @@ -1,14 +1,19 @@ -<%# locals:(name:, path:, icon:, icon_custom:, active:, mobile_only: false) %> +<%# locals: (name:, path:, icon:, icon_custom:, active:, mobile_only: false, beta: false) %> <%= link_to path, class: "space-y-1 group block relative pb-1", aria: { current: ("page" if active) } do %>
<%= tag.div class: class_names("w-4 h-1 lg:w-1 lg:h-4 rounded-bl-sm rounded-br-sm lg:rounded-tr-sm lg:rounded-br-sm lg:rounded-bl-none", "bg-nav-indicator" => active) %> <%= tag.div class: class_names( - "w-8 h-8 flex items-center justify-center mx-auto rounded-lg", + "w-8 h-8 flex items-center justify-center mx-auto rounded-lg relative", active ? "bg-container shadow-xs text-primary" : "group-hover:bg-surface-hover text-secondary" ) do %> <%= icon(icon, color: active ? "current" : "default", custom: icon_custom) %> + <% if beta %> + + <%= render DS::Pill.new(tone: :violet, dot_only: true, title: "Beta") %> + + <% end %> <% end %>
From f7adcac2eb4903d3a8490df6169b6086702d3603 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 20:27:35 +0200 Subject: [PATCH 07/28] fix(DS::Pill): readable contrast in light mode + drop pill from Goal detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bind CSS `color-scheme` to Sure's `data-theme` attribute so the pill's `light-dark()` resolves to the side that matches the active theme. In the dark theme it was previously falling back to the light branch. - Darken light-mode pill text 30% with black on top of the 700 stop so the 10–11px uppercase label reads against the violet-50 background. - Drop the Beta pill from the Goal detail page header. A single goal is not the feature; the pill belongs on the feature index, not on each record. --- app/assets/tailwind/sure-design-system/base.css | 11 +++++++++++ app/components/DS/pill.rb | 14 +++++++++----- app/views/goals/show.html.erb | 5 +---- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/app/assets/tailwind/sure-design-system/base.css b/app/assets/tailwind/sure-design-system/base.css index 991cfc4ef..f1e9c822b 100644 --- a/app/assets/tailwind/sure-design-system/base.css +++ b/app/assets/tailwind/sure-design-system/base.css @@ -1,4 +1,15 @@ @layer base { + /* Bind CSS color-scheme to Sure's data-theme attribute so the CSS + `light-dark()` function resolves to the side that matches the active + theme (used by DS::Pill and any future tokens that opt in). */ + :root { + color-scheme: light; + } + + [data-theme="dark"] { + color-scheme: dark; + } + button { @apply cursor-pointer focus-visible:outline-gray-900; } diff --git a/app/components/DS/pill.rb b/app/components/DS/pill.rb index 0e742612c..05733c4f4 100644 --- a/app/components/DS/pill.rb +++ b/app/components/DS/pill.rb @@ -25,12 +25,16 @@ class DS::Pill < DesignSystemComponent end def palette + # Light-mode `text` is mixed 30% with black on top of the 700 stop so + # the 10–11px uppercase label still reads against the very pale 50 + # background. Without the mix the perceptual contrast feels low even + # though the raw ratio passes WCAG. { - violet: { bg: "var(--color-violet-50)", bg_dark: "var(--color-violet-tint-10)", text: "var(--color-violet-700)", text_dark: "var(--color-violet-200)", border: "var(--color-violet-200)", dot: "var(--color-violet-500)", fill: "var(--color-violet-500)" }, - indigo: { bg: "var(--color-indigo-50)", bg_dark: "var(--color-indigo-tint-10)", text: "var(--color-indigo-700)", text_dark: "var(--color-indigo-200)", border: "var(--color-indigo-200)", dot: "var(--color-indigo-500)", fill: "var(--color-indigo-500)" }, - fuchsia: { bg: "var(--color-fuchsia-50)", bg_dark: "var(--color-fuchsia-tint-10)", text: "var(--color-fuchsia-700)", text_dark: "var(--color-fuchsia-200)", border: "var(--color-fuchsia-200)", dot: "var(--color-fuchsia-500)", fill: "var(--color-fuchsia-500)" }, - amber: { bg: "var(--color-yellow-50)", bg_dark: "var(--color-yellow-tint-10)", text: "var(--color-yellow-700)", text_dark: "var(--color-yellow-200)", border: "var(--color-yellow-200)", dot: "var(--color-yellow-500)", fill: "var(--color-yellow-500)" }, - gray: { bg: "var(--color-gray-100)", bg_dark: "var(--color-gray-tint-10)", text: "var(--color-gray-700)", text_dark: "var(--color-gray-200)", border: "var(--color-gray-200)", dot: "var(--color-gray-500)", fill: "var(--color-gray-500)" } + violet: { bg: "var(--color-violet-50)", bg_dark: "var(--color-violet-tint-10)", text: "color-mix(in oklab, var(--color-violet-700), black 30%)", text_dark: "var(--color-violet-200)", border: "var(--color-violet-200)", dot: "var(--color-violet-500)", fill: "var(--color-violet-500)" }, + indigo: { bg: "var(--color-indigo-50)", bg_dark: "var(--color-indigo-tint-10)", text: "color-mix(in oklab, var(--color-indigo-700), black 30%)", text_dark: "var(--color-indigo-200)", border: "var(--color-indigo-200)", dot: "var(--color-indigo-500)", fill: "var(--color-indigo-500)" }, + fuchsia: { bg: "var(--color-fuchsia-50)", bg_dark: "var(--color-fuchsia-tint-10)", text: "color-mix(in oklab, var(--color-fuchsia-700), black 30%)", text_dark: "var(--color-fuchsia-200)", border: "var(--color-fuchsia-200)", dot: "var(--color-fuchsia-500)", fill: "var(--color-fuchsia-500)" }, + amber: { bg: "var(--color-yellow-50)", bg_dark: "var(--color-yellow-tint-10)", text: "color-mix(in oklab, var(--color-yellow-700), black 30%)", text_dark: "var(--color-yellow-200)", border: "var(--color-yellow-200)", dot: "var(--color-yellow-500)", fill: "var(--color-yellow-500)" }, + gray: { bg: "var(--color-gray-100)", bg_dark: "var(--color-gray-tint-10)", text: "color-mix(in oklab, var(--color-gray-700), black 30%)", text_dark: "var(--color-gray-200)", border: "var(--color-gray-200)", dot: "var(--color-gray-500)", fill: "var(--color-gray-500)" } }[tone] end diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb index cbdf63cf9..7036e1638 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -4,10 +4,7 @@ <%= render Goals::AvatarComponent.new(goal: @goal, size: "xl") %>
-
-

<%= @goal.name %>

- <%= render DS::Pill.new(label: "Beta", size: :md) %> -
+

<%= @goal.name %>

<%= @goal.header_summary %>

<% last_days = @goal.last_matched_pledge_days_ago %> <% unless last_days.nil? %> From 7b4cee60aa29c7999416da9ab4a1e583ece67435 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 20:37:46 +0200 Subject: [PATCH 08/28] docs(beta-gating): document main-nav dot marker via beta: local The nav-item partial already supports a `beta: true` local that overlays the DS::Pill dot on the icon, but the gating guide didn't show how to wire a gated nav entry through it. Add a short "Gating the main nav" section with the compact-array pattern, and mention the flag in the GA removal checklist. --- docs/llm-guides/gating-a-beta-feature.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/llm-guides/gating-a-beta-feature.md b/docs/llm-guides/gating-a-beta-feature.md index dc6c90979..df120b31e 100644 --- a/docs/llm-guides/gating-a-beta-feature.md +++ b/docs/llm-guides/gating-a-beta-feature.md @@ -57,6 +57,23 @@ Wrap the relevant fragment in the helper: Same pattern works for dashboard widgets, scoreboard cards, anything that surfaces beta data alongside non-beta 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 `beta:` 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 beta surface. + +Build the nav-item hash conditionally inside the `beta_features_enabled?` branch and set `beta: true` on it. The compact form using `Array#compact` keeps the array clean: + +```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) }, + (beta_features_enabled? ? { name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path), beta: true } : nil), + { name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true } +].compact %> +``` + +Two things happen from this single change: non-beta users never see the entry (the `nil` gets compacted out) and beta users see the entry with the dot marker (the partial reads `beta:` and renders the pill). You don't need to touch `_nav_item.html.erb` itself. + ## Marking the feature in the UI When a beta surface renders for an opted-in user, mark it. The pill component lives in the design system: @@ -114,7 +131,7 @@ When a feature moves from beta to general availability, removing the gate is a s 1. Drop the `before_action :require_beta_features!` line from the controller. 2. Unwrap the `if beta_features_enabled?` blocks in views. -3. Drop the `DS::Pill` markers from headers, nav, and section titles. +3. Drop the `DS::Pill` markers from headers and section titles, and drop the `beta: true` flag from the nav-item hash. 4. Delete the controller / view tests that exercise the redirect. Grep for `require_beta_features!` and `beta_features_enabled?` near your feature to confirm nothing's left behind. From af647a9cfc903ec3f06f1a4579836dde6627f3e1 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 20:43:02 +0200 Subject: [PATCH 09/28] feat(beta-gating): beta_gated_nav_item helper auto-marks gated entries Wraps the conditional + dot wiring into a single call so adding a new beta nav entry doesn't require remembering to set `beta: true` by hand or duplicating the `beta_features_enabled?` check. Naming mirrors the existing `BetaGateable` concern. --- app/helpers/application_helper.rb | 10 ++++++++++ app/views/layouts/application.html.erb | 2 +- docs/llm-guides/gating-a-beta-feature.md | 8 ++++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d15266e03..e207f267e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -59,6 +59,16 @@ module ApplicationHelper current_page?(path) || (request.path.start_with?(path) && path != "/") end + # Wraps a nav-item hash so a single call performs both halves of a + # beta-gated entry: returns `nil` for users without the flag (so the + # entry never reaches the rendered nav), and stamps `beta: true` on + # the hash for users with the flag (so the partial paints the violet + # dot on the icon). Use inside an `Array#compact` nav-items list. + def beta_gated_nav_item(item) + return nil unless beta_features_enabled? + item.merge(beta: true) + end + # Wrapper around I18n.l to support custom date formats def format_date(object, format = :default, options = {}) date = object.to_date diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 73a6ced37..80f66cd48 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -11,7 +11,7 @@ else { 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) }, - (beta_features_enabled? ? { name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path), beta: true } : nil), + beta_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 %> diff --git a/docs/llm-guides/gating-a-beta-feature.md b/docs/llm-guides/gating-a-beta-feature.md index df120b31e..bc0b1d5ed 100644 --- a/docs/llm-guides/gating-a-beta-feature.md +++ b/docs/llm-guides/gating-a-beta-feature.md @@ -61,18 +61,18 @@ Same pattern works for dashboard widgets, scoreboard cards, anything that surfac 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 `beta:` 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 beta surface. -Build the nav-item hash conditionally inside the `beta_features_enabled?` branch and set `beta: true` on it. The compact form using `Array#compact` keeps the array clean: +Use the `beta_gated_nav_item` helper to wrap the entry. It returns `nil` for non-beta users (so the entry never enters the nav, once `Array#compact` runs) and stamps `beta: 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) }, - (beta_features_enabled? ? { name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path), beta: true } : nil), + beta_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 %> ``` -Two things happen from this single change: non-beta users never see the entry (the `nil` gets compacted out) and beta users see the entry with the dot marker (the partial reads `beta:` and renders the pill). You don't need to touch `_nav_item.html.erb` itself. +You don't need to touch `_nav_item.html.erb` or set `beta: true` by hand. Adding a new beta nav entry is one helper call wrapped around the same hash you'd write anyway. ## Marking the feature in the UI @@ -131,7 +131,7 @@ When a feature moves from beta to general availability, removing the gate is a s 1. Drop the `before_action :require_beta_features!` line from the controller. 2. Unwrap the `if beta_features_enabled?` blocks in views. -3. Drop the `DS::Pill` markers from headers and section titles, and drop the `beta: true` flag from the nav-item hash. +3. Drop the `DS::Pill` markers from headers and section titles, and unwrap the `beta_gated_nav_item(...)` call back into a plain nav-item hash. 4. Delete the controller / view tests that exercise the redirect. Grep for `require_beta_features!` and `beta_features_enabled?` near your feature to confirm nothing's left behind. From 30c2638fd00647cbcd172c98791703b9c2620e32 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 20:46:56 +0200 Subject: [PATCH 10/28] refactor(css): move .goal-avatar rules out of application.css Goals-specific styling doesn't belong in the global stylesheet. Extract the avatar tint + theme-aware text color into a topical goals.css and @import it alongside the other feature CSS files. --- app/assets/tailwind/application.css | 15 +-------------- app/assets/tailwind/goals.css | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 14 deletions(-) create mode 100644 app/assets/tailwind/goals.css diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index ddbda9b04..5cdf1a3bf 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -14,6 +14,7 @@ @import "./date-picker-dark-mode.css"; @import "./print-report.css"; @import "./privacy-mode.css"; +@import "./goals.css"; @layer components { .pcr-app{ @@ -188,20 +189,6 @@ scrollbar-width:none } -/* Tinted-bg + colored-content avatar used by Goals::AvatarComponent and - the goals color/icon picker. Theme-aware text color: light mode darkens - the letter/icon so pale palette entries (cyan-300, green-300, etc.) keep - ~4.5:1 contrast against the 10%-mix tint over white. Dark mode reverts - to the full color so the letter doesn't disappear against the near-black - surface. */ -.goal-avatar { - background-color: color-mix(in oklab, var(--avatar-color) 10%, transparent); - color: color-mix(in oklab, var(--avatar-color) 55%, black); -} -[data-theme="dark"] .goal-avatar { - color: var(--avatar-color); -} - .invite_code [data-clipboard-target="iconDefault"], .invite_code [data-clipboard-target="iconSuccess"] { transition: opacity 0.2s; diff --git a/app/assets/tailwind/goals.css b/app/assets/tailwind/goals.css new file mode 100644 index 000000000..a42e81a1b --- /dev/null +++ b/app/assets/tailwind/goals.css @@ -0,0 +1,17 @@ +/* Goals-feature styles. Lives outside application.css so component-specific + rules don't pile up in the global stylesheet. */ + +/* Tinted-bg + colored-content avatar used by Goals::AvatarComponent and + the goals color/icon picker. Theme-aware text color: light mode darkens + the letter/icon so pale palette entries (cyan-300, green-300, etc.) keep + ~4.5:1 contrast against the 10%-mix tint over white. Dark mode reverts + to the full color so the letter doesn't disappear against the near-black + surface. */ +.goal-avatar { + background-color: color-mix(in oklab, var(--avatar-color) 10%, transparent); + color: color-mix(in oklab, var(--avatar-color) 55%, black); +} + +[data-theme="dark"] .goal-avatar { + color: var(--avatar-color); +} From fb36ac319a59c46bcbd5622d7a9b5c293e62af48 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 20:57:22 +0200 Subject: [PATCH 11/28] fix(goals): validate color format + restore cascade on drop migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add hex-format validation on Goal#color so submissions can't smuggle arbitrary CSS into the style attribute on the avatar / picker preview. The picker accepts custom hexes, so format validation (not inclusion) is the right shape — anything not matching #RRGGBB is rejected at the model boundary. - Fix the on_delete in the down block of drop_goal_contributions to match the original cascade. Restoring with restrict was a schema drift that would have shifted referential behavior after a rollback. --- app/models/goal.rb | 1 + db/migrate/20260514120001_drop_goal_contributions.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/goal.rb b/app/models/goal.rb index dbc96e4df..c020e9982 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -5,6 +5,7 @@ class Goal < ApplicationRecord ICONS = Category.icon_codes validates :icon, inclusion: { in: ICONS, allow_nil: true } + validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }, allow_nil: true belongs_to :family has_many :goal_accounts, dependent: :destroy diff --git a/db/migrate/20260514120001_drop_goal_contributions.rb b/db/migrate/20260514120001_drop_goal_contributions.rb index 7feff22be..ead75eb87 100644 --- a/db/migrate/20260514120001_drop_goal_contributions.rb +++ b/db/migrate/20260514120001_drop_goal_contributions.rb @@ -6,7 +6,7 @@ class DropGoalContributions < ActiveRecord::Migration[7.2] def down create_table :goal_contributions, id: :uuid do |t| t.references :goal, null: false, foreign_key: { on_delete: :cascade }, type: :uuid - t.references :account, null: false, foreign_key: { on_delete: :restrict }, type: :uuid + t.references :account, null: false, foreign_key: { on_delete: :cascade }, type: :uuid t.decimal :amount, precision: 19, scale: 4, null: false t.string :currency, null: false t.string :source, default: "manual", null: false From 67726f88a60c30f8def36e2348d87b9675ebb60d Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 21:00:18 +0200 Subject: [PATCH 12/28] fix(goals): clear state-dependent caches on AASM transition + harden sweep job - Goal: `display_status` and `projection_summary` memoize a value that depends on the AASM state column. Without resetting them after a transition the same instance keeps returning the pre-transition value. Hook `after_all_transitions :reset_state_dependent_caches!` undoes the memos so post-`archive!` / post-`pause!` reads see the new state. - SweepExpiredGoalPledgesJob: the inner rescue covered per-pledge failures but not cursor-phase failures (DB blip, OOM mid-batch). Add an outer rescue that reports + re-raises so Sentry sees the failure and Sidekiq retries the job. --- app/jobs/sweep_expired_goal_pledges_job.rb | 8 ++++++++ app/models/goal.rb | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/jobs/sweep_expired_goal_pledges_job.rb b/app/jobs/sweep_expired_goal_pledges_job.rb index 29a327683..bf2214764 100644 --- a/app/jobs/sweep_expired_goal_pledges_job.rb +++ b/app/jobs/sweep_expired_goal_pledges_job.rb @@ -3,6 +3,10 @@ class SweepExpiredGoalPledgesJob < ApplicationJob # Per-record rescue so one bad pledge (lock contention, missing FK, # stale row) doesn't abort the sweep and leave the rest open forever. + # The outer rescue catches query-phase failures (DB blip, OOM mid-cursor) + # so a single bad batch surfaces to Sentry rather than disappearing into + # Sidekiq's generic retry log. Re-raise after reporting so the retry + # behaviour still kicks in. def perform GoalPledge.open_and_expired_now.find_each do |pledge| pledge.expire! @@ -10,5 +14,9 @@ class SweepExpiredGoalPledgesJob < ApplicationJob Rails.logger.error("SweepExpiredGoalPledgesJob: pledge ##{pledge.id} expire failed: #{e.class}: #{e.message}") Sentry.capture_exception(e) if defined?(Sentry) end + rescue StandardError => e + Rails.logger.error("SweepExpiredGoalPledgesJob: cursor failed: #{e.class}: #{e.message}") + Sentry.capture_exception(e) if defined?(Sentry) + raise end end diff --git a/app/models/goal.rb b/app/models/goal.rb index c020e9982..044f8f70d 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -36,6 +36,8 @@ class Goal < ApplicationRecord end aasm column: :state do + after_all_transitions :reset_state_dependent_caches! + state :active, initial: true state :paused state :completed @@ -398,6 +400,16 @@ class Goal < ApplicationRecord end private + # Cleared after every AASM transition. The state column drives the + # display_status / projection_summary memos; without this the same + # instance keeps returning the pre-transition value if a controller + # calls archive! / pause! and then renders without reload. + def reset_state_dependent_caches! + %i[@display_status @projection_summary].each do |ivar| + remove_instance_variable(ivar) if instance_variable_defined?(ivar) + end + end + # K/M shorthand for narrow chart annotations (axis ticks, projection # short-form, pending-pledge badge). Locale-aware currency symbol via # Money so the chart matches the rest of the app for EUR/GBP families. From 79c81377acd91a6d29034c6b8589eba7a6eed276 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 21:02:05 +0200 Subject: [PATCH 13/28] i18n + a11y(goals): extract picker strings + drop redundant status-pill aria-label - Color picker had four hardcoded English strings ("Color", "Icon", "Poor contrast, choose darker color or", "auto-adjust."). Move them under `goals.color_picker.*` and call them through `t()`. CLAUDE.md requires every user-facing string go through i18n. - Status pill duplicated its visible label in `aria-label`, which makes screen readers ignore the visible text. Drop the override so the visible label is the accessible name. --- app/components/goals/status_pill_component.html.erb | 2 +- app/views/goals/_color_picker.html.erb | 8 ++++---- config/locales/views/goals/en.yml | 5 +++++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/components/goals/status_pill_component.html.erb b/app/components/goals/status_pill_component.html.erb index f254a31f8..548a0b37a 100644 --- a/app/components/goals/status_pill_component.html.erb +++ b/app/components/goals/status_pill_component.html.erb @@ -1,4 +1,4 @@ - + <%= helpers.icon(icon_name, size: "xs", color: "current") %> <%= label %> diff --git a/app/views/goals/_color_picker.html.erb b/app/views/goals/_color_picker.html.erb index d3805d466..ffe30b51a 100644 --- a/app/views/goals/_color_picker.html.erb +++ b/app/views/goals/_color_picker.html.erb @@ -23,7 +23,7 @@ data-color-icon-picker-target="popup">
-

Color

+

<%= t("goals.color_picker.color_heading") %>

<% colors.each do |c| %>
-

Icon

+

<%= t("goals.color_picker.icon_heading") %>

<% icons.each do |icon_name| %>