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.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 })