mirror of
https://github.com/we-promise/sure.git
synced 2026-08-04 16:12:14 +00:00
* fix(insights): correct the budget card's figure, badge noise and toast a11y
Four defects found while reviewing the insights surfaces for hierarchy.
**The budget_at_risk card's focal figure argued against its own headline.**
`insight_key_figure` returned `budget_spent_pct` for both budget cards, so
"2 categories need attention in your budget" displayed "14% / of budget" —
a reassuring number as the visual focus of a warning. It now leads with the
flagged count ("2 / need attention"); budget_on_track keeps the percentage,
where overall consumption genuinely is the subject.
**The "New" pill carried no information.** Visiting /insights marks every
insight read in one `update_all`, so at first paint the pill was on every
row. On the page it becomes a dot — same signal, without an uppercase
tracked chip stealing weight from the title beside it. In the dashboard
widget it goes entirely: the well's header already counts unread ("New · 3")
and, with three rows, the pill was usually on all of them.
**The undo toast was silent to screen readers.** A card leaves the page via
a Turbo `remove`, which announces nothing, and the toast that explains it
had no live region — unlike its neighbour `_sync_toast`, which sets
`role="status" aria-live="polite"`.
**The undo toast could only be closed with a mouse.** Its close affordance
was a bare `icon "x"` with a click action: not focusable, not named. Now a
real `DS::Button`, matching `_sync_toast`.
The controller test asserting a per-row badge is updated to assert the
header count that replaces it, and to lock in the pill's removal.
* feat(insights): acknowledge instead of dismiss, on both surfaces (#2800)
Two complaints about the insight feed: the close (×) control felt wrong,
and clearing an insight was only possible on /insights — not on the
dashboard widget, which is the surface people actually look at.
**The × was lying.** Dismissal has never been permanent. GenerateInsightsJob
resurfaces a row whose bucketed metadata changes materially "even if the user
had read or dismissed the stale version" (its own comment), and 6 of 8
generators scope dedup_key to a month token, so dismissing July's budget card
says nothing about August's. A destructive-looking control was performing a
non-destructive act. It is now "Got it", and the contract is statable:
acknowledgement covers the numbers you saw; new numbers are a new insight.
No migration. The DB value stays "dismissed" and dismissed_at keeps its name;
only the enum key and the vocabulary the code speaks change, so existing rows
stay hidden and become undoable under an honest label.
**The action pyramid was inverted.** The escape hatch was a chromed icon
button in the card's top-right — the strongest secondary scan position — while
the card's actual purpose ("View budget") was a borderless ghost link under
the body text. Both now sit in a footer strip: the subject action gets the
chrome, acknowledging is quiet labelled text beside it, and the key figure
gets the corner to itself instead of competing with a control.
**The widget can clear its own rows.** Each row gains an acknowledge control,
revealed on pointer hover, on keyboard focus, and shown unconditionally on
touch where there is no hover. No gesture, so the section's drag-to-reorder
handlers are untouched. The row becomes a stretched link plus a sibling
button, because button_to renders a <form> and a form cannot nest in an <a>.
The group is named (group/insight). The dashboard <section> is itself a
`.group` for its header controls, and a bare group-hover: matches any ancestor
group — hovering one row, or the section header, revealed every row's control.
Acknowledging re-renders the well rather than removing a row, so the next
insight is promoted into the freed slot; Insight::FEED_LIMIT is now shared
between the two controllers that render it so they cannot drift. Undo restores
the row on both surfaces, and carries autofocus so it is one keystroke away
after the acknowledged card leaves the DOM.
* fix(insights): guard unacknowledge! against non-acknowledged insights
CodeRabbit, Major: an arbitrary/stale PATCH /unacknowledge (e.g. an old
undo-toast link clicked after GenerateInsightsJob has since expired or
resurrected the insight) could force it back to :read regardless of
its actual current state — including pulling an :expired insight back
into visible view.
Guards the transition to only reverse an actual acknowledgement, per
CodeRabbit's suggested fix.
* test(insights): fix stale dismiss_insight_url route from main merge
main's preview-gate test used the pre-rename dismiss/undismiss route names;
this branch renamed those to acknowledge/unacknowledge earlier.
194 lines
6.5 KiB
Ruby
194 lines
6.5 KiB
Ruby
require "test_helper"
|
|
|
|
class InsightsControllerTest < ActionDispatch::IntegrationTest
|
|
setup do
|
|
sign_in @user = users(:family_admin)
|
|
enable_preview_features
|
|
@insight = insights(:spending_anomaly_dining)
|
|
ensure_tailwind_build
|
|
end
|
|
|
|
test "index renders visible insights and marks them read" do
|
|
get insights_url
|
|
|
|
assert_response :success
|
|
assert_match CGI.escapeHTML(@insight.title), response.body
|
|
assert @insight.reload.read?
|
|
end
|
|
|
|
test "turbo prefetch requests do not mark insights read" do
|
|
get insights_url, headers: { "X-Sec-Purpose" => "prefetch" }
|
|
|
|
assert_response :success
|
|
assert @insight.reload.active?
|
|
end
|
|
|
|
# Unread state is carried by the well's header count, not by a pill on every
|
|
# row. The widget shows three rows, so the pill was usually on all of them,
|
|
# repeating what the header already says and crowding each title.
|
|
test "dashboard insights feed counts unread in its header, without per-row badges" do
|
|
get root_url
|
|
|
|
assert_response :success
|
|
assert_select "#insights-feed", count: 1
|
|
assert_select "#insights-feed p", text: /#{Regexp.escape(I18n.t("insights.feed.header_new"))}/
|
|
assert_select "#insights-feed span", text: I18n.t("insights.card.new"), count: 0
|
|
end
|
|
|
|
test "insights feed leads the dashboard for users with a saved order that predates it" do
|
|
@user.update!(preferences: (@user.preferences || {}).merge(
|
|
"section_order" => %w[cashflow_sankey outflows_donut net_worth_chart balance_sheet]
|
|
))
|
|
|
|
get root_url
|
|
|
|
assert_response :success
|
|
feed_position = response.body.index('data-section-key="insights_feed"')
|
|
sankey_position = response.body.index('data-section-key="cashflow_sankey"')
|
|
assert feed_position.present? && feed_position < sankey_position,
|
|
"insights_feed should be prepended, not appended, for saved orders that predate it"
|
|
end
|
|
|
|
test "acknowledge removes the insight from the feed and offers undo via turbo stream" do
|
|
patch acknowledge_insight_url(@insight), as: :turbo_stream
|
|
|
|
assert_response :success
|
|
assert_match "turbo-stream", response.body
|
|
assert_match unacknowledge_insight_path(@insight), response.body
|
|
assert @insight.reload.acknowledged?
|
|
end
|
|
|
|
test "unacknowledge restores the insight as read and re-renders the list" do
|
|
@insight.acknowledge!
|
|
|
|
patch unacknowledge_insight_url(@insight), as: :turbo_stream
|
|
|
|
assert_response :success
|
|
assert_match "insights-list", response.body
|
|
assert_match CGI.escapeHTML(@insight.title), response.body
|
|
assert @insight.reload.read?
|
|
assert_nil @insight.dismissed_at
|
|
end
|
|
|
|
# Acknowledging used to be reachable only from /insights, so the dashboard —
|
|
# the surface people actually look at — could show an insight but not clear it.
|
|
test "dashboard feed rows carry an acknowledge control" do
|
|
get root_url
|
|
|
|
assert_response :success
|
|
assert_select "#insights-feed form[action=?]", acknowledge_insight_path(@insight)
|
|
end
|
|
|
|
# The widget shows the top N, so clearing one has to promote the next into the
|
|
# freed slot rather than leave a gap — hence a re-render, not a row removal.
|
|
test "acknowledge re-renders the dashboard feed so the next insight backfills" do
|
|
family = @user.family
|
|
family.insights.destroy_all
|
|
|
|
# One more than the well holds, same priority so `ordered` falls through to
|
|
# generated_at and the sequence is predictable.
|
|
rows = (Insight::FEED_LIMIT + 1).times.map do |i|
|
|
family.insights.create!(
|
|
insight_type: "idle_cash",
|
|
priority: "high",
|
|
status: "active",
|
|
title: "Test insight #{i}",
|
|
body: "body",
|
|
dedup_key: "idle_cash:test:#{i}",
|
|
generated_at: (i + 1).minutes.ago
|
|
)
|
|
end
|
|
|
|
patch acknowledge_insight_url(rows.first), as: :turbo_stream
|
|
|
|
assert_response :success
|
|
assert_match "insights-feed", response.body
|
|
assert_no_match(/Test insight 0/, response.body)
|
|
assert_match(/Test insight #{Insight::FEED_LIMIT}/, response.body)
|
|
end
|
|
|
|
test "refresh swaps the button into a pending state via turbo stream" do
|
|
assert_enqueued_with(job: GenerateInsightsJob, args: [ { family_id: @user.family_id } ]) do
|
|
post refresh_insights_url, as: :turbo_stream
|
|
end
|
|
|
|
assert_response :success
|
|
assert_match "insights-refresh", response.body
|
|
assert_match CGI.escapeHTML(I18n.t("insights.refresh.checking")), response.body
|
|
end
|
|
|
|
test "cannot acknowledge another family's insight" do
|
|
other_insight = families(:empty).insights.create!(
|
|
insight_type: "idle_cash",
|
|
priority: "low",
|
|
title: "Someone else's insight",
|
|
body: "Body",
|
|
dedup_key: "idle_cash:other:2026-07"
|
|
)
|
|
|
|
patch acknowledge_insight_url(other_insight), as: :turbo_stream
|
|
|
|
assert_response :not_found
|
|
assert other_insight.reload.active?
|
|
end
|
|
|
|
test "refresh enqueues insight generation for the family" do
|
|
assert_enqueued_with(job: GenerateInsightsJob, args: [ { family_id: @user.family_id } ]) do
|
|
post refresh_insights_url
|
|
end
|
|
|
|
assert_redirected_to insights_path
|
|
end
|
|
|
|
# Preview gate. Insights is opt-in via Settings → Preferences, so a user
|
|
# without the flag reaches none of it — not the page, not the dashboard
|
|
# section, not the top-bar entry, and not the job the refresh action would
|
|
# otherwise enqueue.
|
|
test "redirects users without preview access" do
|
|
disable_preview_features
|
|
|
|
get insights_url
|
|
|
|
assert_redirected_to root_path
|
|
assert_match(/preview/i, flash[:alert])
|
|
end
|
|
|
|
test "refresh does not enqueue generation for users without preview access" do
|
|
disable_preview_features
|
|
|
|
assert_no_enqueued_jobs only: GenerateInsightsJob do
|
|
post refresh_insights_url
|
|
end
|
|
|
|
assert_redirected_to root_path
|
|
end
|
|
|
|
test "acknowledge is blocked for users without preview access" do
|
|
disable_preview_features
|
|
|
|
patch acknowledge_insight_url(@insight), as: :turbo_stream
|
|
|
|
assert_redirected_to root_path
|
|
assert @insight.reload.active?
|
|
end
|
|
|
|
test "dashboard omits the insights feed and top-bar entry without preview access" do
|
|
disable_preview_features
|
|
|
|
get root_url
|
|
|
|
assert_response :success
|
|
assert_select "#insights-feed", count: 0
|
|
assert_select "a[href=?]", insights_path, count: 0
|
|
end
|
|
|
|
private
|
|
def enable_preview_features
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true))
|
|
end
|
|
|
|
def disable_preview_features
|
|
@user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false))
|
|
end
|
|
end
|