fix(insights): blur amounts in insight prose under privacy mode

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.
This commit is contained in:
Guillem Arias
2026-07-31 08:55:25 +02:00
parent 9d3879a859
commit f7fbfa079e
4 changed files with 56 additions and 4 deletions

View File

@@ -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.

View File

@@ -11,7 +11,7 @@
<div class="flex items-start justify-between gap-3">
<h3 class="text-sm font-medium text-primary inline-flex items-center gap-2 min-w-0">
<%= 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 %>
</div>
<p class="text-sm text-secondary"><%= insight.body %></p>
<p class="text-sm text-secondary"><%= insight_privacy_text(insight.body) %></p>
</div>
</div>

View File

@@ -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. %>
<span class="text-sm font-medium text-primary truncate block"><%= insight.title %></span>
<p class="text-sm text-secondary truncate"><%= insight.body %></p>
<span class="text-sm font-medium text-primary truncate block"><%= insight_privacy_text(insight.title) %></span>
<p class="text-sm text-secondary truncate"><%= insight_privacy_text(insight.body) %></p>
</div>
<% if (figure = insight_key_figure(insight)) %>

View File

@@ -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 <span class="privacy-sensitive">288.59</span>, which is <span class="privacy-sensitive">142%</span> above your usual <span class="privacy-sensitive">119.01</span>.
HTML
end
test "privacy text handles locale formats with suffix currency and no-break-space grouping" do
rendered = insight_privacy_text("Checking holds 1234,56 € with no activity in the last 45 days.")
assert_includes rendered, %(<span class="privacy-sensitive">1234,56 €</span>)
assert_includes rendered, %(<span class="privacy-sensitive">45</span> 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 <b>big</b>: $1,200.50")
assert_includes rendered, "It&#39;s &lt;b&gt;big&lt;/b&gt;:"
assert_includes rendered, %(<span class="privacy-sensitive">$1,200.50</span>)
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 })