Files
sure/app/controllers/insights_controller.rb
T
Guillem Arias Fauste 2bcef99441 fix(insights): drop the dismiss toast and fix both empty states (#2965)
* fix(insights): drop the dismiss toast and fix both empty states

Three problems with acknowledging an insight, all in the turbo-stream path.

Every dismissal appended an undo toast to the notification tray. Acknowledging
already means "hidden until these numbers change" — GenerateInsightsJob
resurfaces a row whose metadata moves materially, and 6 of 8 generators scope
`dedup_key` to a month — so a toast interrupting the flow bought little.
Removed, along with the now-orphaned `_undo_toast` partial and its two locale
keys.

Dismissing the last insight left the dashboard widget on screen: the stream
re-rendered the well unconditionally, so the section shell stayed with its
header above an empty box until a reload. A full render already drops it
(PagesController#insights_feed_section sets `visible: @feed_insights.any?`);
the stream now removes the whole section to match, targeted by
`[data-section-key='insights_feed']` because the shared dashboard loop emits no
id on the section element.

Dismissing the last insight on /insights left a blank page. The card left via
`turbo_stream.remove`, which emptied #insights-list without re-rendering the
partial that owns the empty state, so "No insights yet" only appeared after a
reload. The list is now replaced rather than the card removed — the same thing
unacknowledge already did.

InsightsController#unacknowledge, its route and Insight#unacknowledge! are kept
and still work; only the toast that reached them is gone, so undo can be
re-wired to a different surface without resurrecting them.

* fix(insights): announce the dismissal now the toast is gone

Removing the undo toast took the only `role=status` element with it, and the
stream also replaces the list containing the "Got it" control the user just
activated — so a screen-reader or keyboard user was left with no confirmation
that anything happened.

Add a shared, visually hidden live region to the notification tray and update
it from the acknowledge stream. It sits outside every stream target and is
rendered with the page, which matters: a live region that arrives together
with its own content is not announced. Updated rather than appended, so
messages replace instead of piling up, and it stays empty (and free) until
something uses it.

This is a general primitive, not an insights one — the tray already holds
`#sync-toast` and `#cta` as stable stream targets, and any flow that changes
the page without leaving something on screen to read can use it.

Verified in a browser: after dismissing, the region reads "Insight dismissed"
at 1x1px with `clip: rect(0,0,0,0)` — announced, invisible.
2026-08-11 06:15:02 +02:00

78 lines
2.6 KiB
Ruby

class InsightsController < ApplicationController
before_action :require_preview_features!
before_action :set_insight, only: %i[acknowledge unacknowledge]
def index
load_feed
@breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("insights.index.title"), nil ] ]
# Viewing the feed is what "read" means here; the New badge for this
# render comes from @unread_ids captured above. Turbo's hover prefetch
# hits this GET before the user actually navigates, so skip the write
# for prefetch requests or badges would clear on hover.
unless prefetch_request?
Current.family.insights.active.update_all(status: "read", read_at: Time.current, updated_at: Time.current)
end
end
def acknowledge
@insight.acknowledge!
# Both surfaces: the response carries streams for the /insights list and the
# dashboard widget, and each page applies only the ones whose targets it has.
# The list (not just the acknowledged card) is reloaded so its empty state
# can take over when the last insight goes.
load_feed
load_widget_feed
respond_to do |format|
format.turbo_stream
format.html { redirect_back_or_to insights_path }
end
end
def unacknowledge
@insight.unacknowledge!
load_feed
load_widget_feed
respond_to do |format|
format.turbo_stream
format.html { redirect_back_or_to insights_path }
end
end
def refresh
GenerateInsightsJob.perform_later(family_id: Current.family.id)
respond_to do |format|
# Swaps the button into its pending state; the job broadcasts the
# refreshed list and the idle button back when it finishes.
format.turbo_stream
format.html { redirect_to insights_path, notice: t("insights.refresh.queued") }
end
end
private
def set_insight
@insight = Current.family.insights.find(params[:id])
end
def load_feed
@insights = Current.family.insights.visible.ordered.to_a
@unread_ids = @insights.select(&:active?).map(&:id).to_set
end
# Acknowledging is reachable from the dashboard widget as well as this page,
# so the response re-renders the widget's top three. Removing a row there
# should promote the next insight into the freed slot, not leave a gap.
def load_widget_feed
@feed_insights = Current.family.insights.visible.ordered.limit(Insight::FEED_LIMIT).to_a
end
# Turbo sends X-Sec-Purpose (the fetch spec forbids setting Sec-Purpose
# from JS) on hover-prefetch requests.
def prefetch_request?
request.headers["X-Sec-Purpose"] == "prefetch" || request.headers["Sec-Purpose"].to_s.include?("prefetch")
end
end