mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)) %>
|
||||
|
||||
@@ -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 1 234,56 € with no activity in the last 45 days.")
|
||||
|
||||
assert_includes rendered, %(<span class="privacy-sensitive">1 234,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's <b>big</b>:"
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user