Skip to content

fix(insights): correct the budget card's figure, badge noise and toast a11y#2799

Open
gariasf wants to merge 3 commits into
mainfrom
fix/insights-card-hierarchy-bugs
Open

fix(insights): correct the budget card's figure, badge noise and toast a11y#2799
gariasf wants to merge 3 commits into
mainfrom
fix/insights-card-hierarchy-bugs

Conversation

@gariasf

@gariasf gariasf commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Four defects found while reviewing the Insights surfaces for hierarchy and affordance. All independent of each other and of the larger affordance work, so they're batched here to land on their own.

This is the first of a short series. The follow-up replaces the corner dismiss × with an acknowledgement in the card's action row and adds the same control to the dashboard widget, which today has no way to clear an insight at all.

1. 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 the card headlined "2 categories need attention in your budget" displayed "14% / of budget" as its focal number — a reassuring figure as the visual anchor of a warning.

It now leads with the flagged count. budget_on_track keeps the percentage, where overall consumption genuinely is the subject.

Insights page: the budget card now shows 2 / need attention, and unread markers are small dots

2. The "New" pill carried no information

InsightsController#index marks every insight read in one update_all, so at first paint the pill was on every row — the only moment it renders is the moment it differentiates nothing.

  • On the page it becomes a dot (DS::Pill(dot_only: true), already in the component's API). 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 only three rows, the pill was usually on all of them — saying the same thing twice while crowding each title.

Dashboard insights widget: header reads New · 3, row titles are clean with no per-row pills

3. The undo toast was silent to screen readers

Dismissing a card removes it via a Turbo remove, which announces nothing. The toast that explains what happened had no live region — unlike its neighbour shared/notifications/_sync_toast, which sets role="status" aria-live="polite". Now it matches.

4. The undo toast could only be closed with a mouse

Its close affordance was a bare icon "x" with a click->element-removal#remove action — an SVG, so neither focusable nor named. A keyboard user could reach Undo but never dismiss the toast. Now a real DS::Button(variant: "icon") with an aria-label, again matching _sync_toast.

Notes

Test plan

  • bin/rails test test/helpers/insights_helper_test.rb test/controllers/insights_controller_test.rb test/models/insight_test.rb — 31 runs, 0 failures
  • bin/rubocop clean on the three Ruby files
  • bundle exec erb_lint clean on the three templates
  • Verified in a browser: at-risk card reads "2 / need attention", budget_spent_pct still correct on an on-track card, unread dots render on the page, widget header shows "New · 3" with no per-row pills, undo toast reachable and closable by keyboard

Summary by CodeRabbit

  • New Features
    • Budget risk insights now show the number of items needing attention (and “on track” continues as a percentage of budget).
    • Acknowledge/undo replaces dismiss/undismiss for insights, including Turbo Stream updates to keep feeds in sync.
  • UI Improvements
    • “New” indicators use a simplified dot-only presentation.
    • Unread state is shown via the feed header (not per card).
    • Improved accessibility and localized labels for toast notifications; updated acknowledge/undo controls.
  • Tests
    • Updated controller/model/job tests to cover the new unread and acknowledge/undo behavior.

…t 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.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dfcdd15c-f84e-4633-95e2-a07bd33f1b1b

📥 Commits

Reviewing files that changed from the base of the PR and between 336ee11 and ea09b46.

📒 Files selected for processing (2)
  • app/models/insight.rb
  • test/models/insight_test.rb
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/models/insight_test.rb
  • app/models/insight.rb

📝 Walkthrough

Walkthrough

Insights now use acknowledgement and unacknowledgement actions across status handling, routes, controllers, Turbo Streams, cards, and dashboard feeds. Budget figures distinguish attention counts from budget percentages, and unread indicators and toast accessibility are updated.

Changes

Insights acknowledgement flow

Layer / File(s) Summary
Acknowledgement status and routing
app/models/insight.rb, app/controllers/insights_controller.rb, config/routes.rb, app/controllers/pages_controller.rb, test/models/insight_test.rb, test/jobs/generate_insights_job_test.rb
Dismissal terminology is replaced with acknowledgement behavior, shared feed limits are introduced, and lifecycle tests cover state restoration and generator behavior.
Turbo Stream acknowledgement updates
app/views/insights/acknowledge.turbo_stream.erb, app/views/insights/unacknowledge.turbo_stream.erb, app/views/insights/_undo_toast.html.erb
Acknowledgement removes the card, refreshes the dashboard feed, and adds an undo toast; unacknowledgement removes the toast and refreshes both lists.
Feed and card interaction controls
app/views/insights/_insight_card.html.erb, app/views/pages/dashboard/_insights_feed.html.erb, config/locales/views/insights/en.yml, test/controllers/insights_controller_test.rb
Cards and dashboard rows provide progressive acknowledge controls, unread state is represented by dot/header indicators, and related localization and controller coverage are updated.
Budget insight figure handling
app/helpers/insights_helper.rb, config/locales/views/insights/en.yml, test/helpers/insights_helper_test.rb
Budget-at-risk insights use the count and “need attention” caption; budget-on-track insights retain the percentage and “of budget” caption.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant InsightCard
  participant InsightsController
  participant Insight
  participant DashboardFeed
  participant NotificationTray
  InsightCard->>InsightsController: PATCH acknowledge
  InsightsController->>Insight: acknowledge!
  InsightsController->>DashboardFeed: reload visible feed
  InsightsController->>InsightCard: remove acknowledged card
  InsightsController->>NotificationTray: append undo toast
  NotificationTray->>InsightsController: PATCH unacknowledge
  InsightsController->>Insight: unacknowledge!
  InsightsController->>DashboardFeed: refresh feed and insight list
Loading

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • we-promise/sure#2550: Introduces the dismissal-based insight flow that this PR refactors into acknowledgement.
  • we-promise/sure#2754: Modifies the same budget insight key-figure handling.
  • we-promise/sure#1840: Overlaps with the accessible icon-button markup used for insight undo and close controls.

Suggested reviewers: jjmata

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main user-facing fixes: budget figure correction, badge simplification, and toast accessibility.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/insights-card-hierarchy-bugs
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/insights-card-hierarchy-bugs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/controllers/insights_controller_test.rb (1)

33-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the unread count, not only the header label.

The current selector would pass if the header rendered “New” but omitted the numeric count. Match the separator and a number, or assert the fixture’s expected count, so this test verifies the new contract.

Proposed test adjustment
-    assert_select "`#insights-feed` p", text: /#{Regexp.escape(I18n.t("insights.feed.header_new"))}/
+    assert_select "`#insights-feed` p", text: /#{Regexp.escape(I18n.t("insights.feed.header_new"))}.*\d+/
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/controllers/insights_controller_test.rb` around lines 33 - 34, Update
the insights feed assertion in the relevant controller test to verify the unread
count as part of the header text, not just the “New” label. Match the expected
separator and numeric count, or use the fixture’s known count, while preserving
the existing header selector and zero-card assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/controllers/insights_controller_test.rb`:
- Around line 33-34: Update the insights feed assertion in the relevant
controller test to verify the unread count as part of the header text, not just
the “New” label. Match the expected separator and numeric count, or use the
fixture’s known count, while preserving the existing header selector and
zero-card assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: df923547-b080-4553-9253-7701efb64444

📥 Commits

Reviewing files that changed from the base of the PR and between f3afcf9 and f75b401.

📒 Files selected for processing (7)
  • app/helpers/insights_helper.rb
  • app/views/insights/_insight_card.html.erb
  • app/views/insights/_undo_toast.html.erb
  • app/views/pages/dashboard/_insights_feed.html.erb
  • config/locales/views/insights/en.yml
  • test/controllers/insights_controller_test.rb
  • test/helpers/insights_helper_test.rb

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/models/insight.rb`:
- Around line 61-64: Update Insight#unacknowledge! to perform the state change
only when the insight is currently acknowledged; leave expired or otherwise
non-acknowledged insights unchanged, while preserving the existing read status
and timestamp behavior for valid reversals.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e677dd4b-217a-4be3-ad02-cb15ce4bc407

📥 Commits

Reviewing files that changed from the base of the PR and between f75b401 and 336ee11.

📒 Files selected for processing (15)
  • app/controllers/insights_controller.rb
  • app/controllers/pages_controller.rb
  • app/models/insight.rb
  • app/views/insights/_insight_card.html.erb
  • app/views/insights/_undo_toast.html.erb
  • app/views/insights/acknowledge.turbo_stream.erb
  • app/views/insights/dismiss.turbo_stream.erb
  • app/views/insights/unacknowledge.turbo_stream.erb
  • app/views/insights/undismiss.turbo_stream.erb
  • app/views/pages/dashboard/_insights_feed.html.erb
  • config/locales/views/insights/en.yml
  • config/routes.rb
  • test/controllers/insights_controller_test.rb
  • test/jobs/generate_insights_job_test.rb
  • test/models/insight_test.rb
💤 Files with no reviewable changes (2)
  • app/views/insights/undismiss.turbo_stream.erb
  • app/views/insights/dismiss.turbo_stream.erb
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/views/pages/dashboard/_insights_feed.html.erb

Comment thread app/models/insight.rb

jjmata commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Reviewed the diff, files, and prior CodeRabbit passes. Notes on the three bundled fixes:

1. Budget card figure (app/helpers/insights_helper.rb:45-53)
Traced facts["count"] back to Insight::Generators::BudgetInsightGenerator#at_risk_insight (app/models/insight/generators/budget_insight_generator.rb:37-61): count is flagged.size (over + near categories), the same number driving the card's own title (insights.titles.budget_at_risk, count: flagged.size). So the fix makes the key figure consistent with the headline instead of showing overall budget_spent_pct, which read as reassurance next to a warning. budget_on_trackcorrectly keepsbudget_spent_pct, matching its title (no count). The generator itself is untouched, so this fact was already being persisted on every row — no backfill/migration concern for historical insights. Test coverage is solid and specific: test/helpers/insights_helper_test.rb adds one test per branch (budget_at_risk"2"/"need attention", budget_on_track"62%"/"of budget"), exercising insight_key_figure` directly per the project's query-testing convention. Good.

2. Badge noise reduction
The dashboard widget dropping the per-row "New" pill in favor of the header's New · N count, and the insight card switching to dot_only: true, both check out — DS::Pill's dot_only mode renders an accessible dot (role="img" + aria-label/title fallback via ds.pill.aria_label), so this isn't a silent regression for screen-reader users, just less visual noise. test/controllers/insights_controller_test.rb was updated to assert the per-row pill is gone (count: 0) and the header count renders.
Minor, non-blocking: the updated test only asserts the header text matches insights.feed.header_new, not that the numeric count actually appears — CodeRabbit flagged this in an earlier pass (assert_select "#insights-feed p" doesn't check for a trailing digit) and it's still open. Low value to block on, but easy to tighten with a \d+ in the regex.

3. Toast a11y (app/views/insights/_undo_toast.html.erb)
This is a real fix, not cosmetic: the card leaves the DOM via turbo_stream.remove (silent to AT), and the toast now carries role="status" + aria-live="polite", matching the existing shared/notifications/_sync_toast contract. The close glyph is now a real DS::Button (type="button", aria-label) instead of a bare <span data-action> with no accessible name or focusability. autofocus: true on Undo is a sensible touch given Turbo's documented behavior of focusing the first [autofocus] element in stream-rendered content, replacing focus that would otherwise drop to <body>.

i18n: only config/locales/views/insights/en.yml got the new/changed keys (card.acknowledge, acknowledge_title, acknowledged, feed.acknowledge_aria, figures.need_attention); fr.yml/pl.yml/tr.yml weren't touched. config.i18n.fallbacks = true (config/application.rb) means this won't raise, just renders English strings in other locales until translations catch up — flagging per CLAUDE.md's "update locale files for each changed element" guidance, but likely consistent with how this repo already handles translation lag.

Pre-existing, still open: CodeRabbit's review-thread comment on Insight#unacknowledge! (no change since) — it doesn't guard on acknowledged? before flipping status to read. Not introduced by this PR (the old undismiss! had the same gap), but worth a look separately: if GenerateInsightsJob reactivates an insight to active while an old undo toast for it is still on screen (metadata changed materially while acknowledged), clicking that stale Undo would downgrade the freshly-reactivated active insight to read, losing its "new" status. Narrow race, not a blocker for this PR.

Overall: the rename (dismissacknowledge) is thorough and consistent across model/controller/routes/views/tests, with no dangling references. The budget figure fix is correct and well-tested.


Generated by Claude Code

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

3 participants