From f7fbfa079ee3d1229c0ca8ef3a0f860539b57bad Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Fri, 31 Jul 2026 08:55:25 +0200 Subject: [PATCH] fix(insights): blur amounts in insight prose under privacy mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insight titles and bodies are stored as finished prose with the amounts already interpolated (by the i18n template or the LLM writer), so the dashboard feed and insight cards rendered raw figures even when the hide-numbers toggle was active — only the right-aligned key figure was tagged privacy-sensitive. Add InsightsHelper#insight_privacy_text, which wraps each numeric fragment (currency amounts, percentages, bare counts, including suffix-currency and no-break-space locale formats) in a privacy-sensitive span at render time, and use it for the title and body in both the dashboard insights feed and the insight card. The sentence stays readable while privacy mode blurs the numbers. The helper splits the raw text before escaping and reassembles it with safe_join, so HTML in stored prose is still escaped and digit-bearing entities like ' are never mangled by the number regex. --- app/helpers/insights_helper.rb | 24 ++++++++++++++++ app/views/insights/_insight_card.html.erb | 4 +-- .../pages/dashboard/_insights_feed.html.erb | 4 +-- test/helpers/insights_helper_test.rb | 28 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/app/helpers/insights_helper.rb b/app/helpers/insights_helper.rb index 33ac31d85..0d1628589 100644 --- a/app/helpers/insights_helper.rb +++ b/app/helpers/insights_helper.rb @@ -14,6 +14,30 @@ module InsightsHelper INSIGHT_ICONS.fetch(insight.insight_type, "lightbulb") end + # Matches the numeric fragments inside insight prose: currency amounts + # ("€288.59", "1 234,56 €"), percentages ("142%"), and bare counts. Digit + # groups may be separated by ".", ",", or the (narrow) no-break spaces some + # locales format with, but must start and end on a digit so sentence + # punctuation stays outside the match. + INSIGHT_NUMERIC_FRAGMENT = / + (?:\p{Sc}[\s\u00A0\u202F]?)? # currency symbol prefix + \d(?:[\d.,\s\u00A0\u202F]*\d)? # digits with grouping separators + (?:[\s\u00A0\u202F]?(?:%|\p{Sc}))? # percent or currency symbol suffix + /x + + # Insight titles and bodies are stored as finished prose with the amounts + # already interpolated (by the i18n template or the LLM writer), so unlike + # the rest of the app the figures can't be tagged where they're formatted. + # This wraps each numeric fragment in a privacy-sensitive span at render + # time so privacy mode blurs the numbers but the sentence stays readable. + def insight_privacy_text(text) + safe_join( + text.to_s.split(/(#{INSIGHT_NUMERIC_FRAGMENT})/o).map.with_index do |part, index| + index.odd? ? tag.span(part, class: "privacy-sensitive") : part + end + ) + end + # "Savings rate · June" / "Cash flow · Next 30 days" — the card's meta line. # Uses the insight's stored period; falls back to the subject (account or # merchant name from facts) for insights without one. diff --git a/app/views/insights/_insight_card.html.erb b/app/views/insights/_insight_card.html.erb index ef3cba58c..68a363e04 100644 --- a/app/views/insights/_insight_card.html.erb +++ b/app/views/insights/_insight_card.html.erb @@ -11,7 +11,7 @@

- <%= insight.title %> + <%= insight_privacy_text(insight.title) %> <% if unread %> <%# A dot, not a labelled chip. Visiting this page marks every insight read in one go, so at first paint the badge is on *every* row and @@ -32,7 +32,7 @@ <% end %>

-

<%= insight.body %>

+

<%= insight_privacy_text(insight.body) %>

diff --git a/app/views/pages/dashboard/_insights_feed.html.erb b/app/views/pages/dashboard/_insights_feed.html.erb index 3113c9b3c..c2160d6f7 100644 --- a/app/views/pages/dashboard/_insights_feed.html.erb +++ b/app/views/pages/dashboard/_insights_feed.html.erb @@ -55,8 +55,8 @@ unread ones ("New · 3"), and with only three rows the pill was usually on all of them — saying the same thing twice while crowding the title. %> - <%= insight.title %> -

<%= insight.body %>

+ <%= insight_privacy_text(insight.title) %> +

<%= insight_privacy_text(insight.body) %>

<% if (figure = insight_key_figure(insight)) %> diff --git a/test/helpers/insights_helper_test.rb b/test/helpers/insights_helper_test.rb index fbf199ce7..13b55c886 100644 --- a/test/helpers/insights_helper_test.rb +++ b/test/helpers/insights_helper_test.rb @@ -141,6 +141,34 @@ class InsightsHelperTest < ActionView::TestCase assert_equal "of budget", caption end + test "privacy text wraps amounts, percentages and counts in privacy-sensitive spans" do + body = "Your grocery spending is at €288.59, which is 142% above your usual €119.01." + + rendered = insight_privacy_text(body) + + assert_predicate rendered, :html_safe? + assert_equal <<~HTML.strip, rendered + Your grocery spending is at €288.59, which is 142% above your usual €119.01. + HTML + end + + test "privacy text handles locale formats with suffix currency and no-break-space grouping" do + rendered = insight_privacy_text("Checking holds 1 234,56 € with no activity in the last 45 days.") + + assert_includes rendered, %(1 234,56 €) + assert_includes rendered, %(45 days) + end + + test "privacy text leaves numberless prose untouched and escapes HTML" do + assert_equal "Is Netflix still active?", insight_privacy_text("Is Netflix still active?") + assert_equal "", insight_privacy_text(nil) + + rendered = insight_privacy_text("It's big: $1,200.50") + + assert_includes rendered, "It's <b>big</b>:" + assert_includes rendered, %($1,200.50) + end + test "action link resolves the stored subject and disappears when it cannot" do account = families(:dylan_family).accounts.visible.first resolvable = build_insight("idle_cash", metadata: { "account_id" => account.id })