From cc070853b7c1c21d20e63240ffe19b7be2910c6e Mon Sep 17 00:00:00 2001 From: Abhinav Dhiman <8640877+ahnv@users.noreply.github.com> Date: Sun, 31 May 2026 19:35:54 +0530 Subject: [PATCH] fix: Replace platform-wide broadcast_refresh with sync toast (#1964) * fix: Replace platform-wide broadcast_refresh with sync toast Instead of calling family.broadcast_refresh on every sync completion (which reloads the page for all connected family members), broadcast a lightweight static toast to the existing notification-tray. A new sync-toast Stimulus controller handles two cases: - User is idle (no focused form): auto-reloads after 500ms - User is mid-form: toast stays visible with a manual Refresh button This prevents in-progress form state from being wiped when a background sync fires (e.g. adding a transaction, filling an import form). The toast partial contains no user-scoped data, so the Current.user nil constraint in background jobs is no longer a concern. * fix(a11y): add explicit button types and aria-label to sync toast controls * fix(sync-toast): improve interaction detection and replace broadcast strategy - Increase auto-refresh delay from 500ms to 2000ms - Expand interaction detection to include contentEditable, dialogs, and role="dialog" elements - Switch from broadcast_append_to to broadcast_replace_to with dedicated #sync-toast target - Add explicit id="sync-toast" to partial for targeted replacement - Move sync_toast i18n keys from defaults/en.yml to views/shared/en.yml * fix(sync-toast): replace hardcoded white icon color with inverse token --- .../controllers/sync_toast_controller.js | 33 +++++++++++++++++++ app/models/family/sync_complete_event.rb | 18 +++++++--- app/views/layouts/shared/_htmldoc.html.erb | 1 + .../shared/notifications/_sync_toast.html.erb | 23 +++++++++++++ config/locales/views/shared/en.yml | 3 ++ 5 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 app/javascript/controllers/sync_toast_controller.js create mode 100644 app/views/shared/notifications/_sync_toast.html.erb diff --git a/app/javascript/controllers/sync_toast_controller.js b/app/javascript/controllers/sync_toast_controller.js new file mode 100644 index 000000000..567f54635 --- /dev/null +++ b/app/javascript/controllers/sync_toast_controller.js @@ -0,0 +1,33 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="sync-toast" +// +// Shown when a background sync completes and the family's data changes. +// - If the user is not interacting with a form, auto-reloads after a short delay. +// - If the user is mid-form, the toast stays visible so they can choose when to refresh. +export default class extends Controller { + static values = { + autoRefreshDelay: { type: Number, default: 2000 }, + }; + + connect() { + if (!this.#userIsInteracting()) { + this._timer = setTimeout(() => this.refresh(), this.autoRefreshDelayValue); + } + } + + disconnect() { + clearTimeout(this._timer); + } + + refresh() { + clearTimeout(this._timer); + window.location.reload(); + } + + #userIsInteracting() { + const el = document.activeElement; + if (!el || el === document.body || el === document.documentElement) return false; + return el.isContentEditable || el.closest("form, dialog, [role='dialog']") !== null; + } +} diff --git a/app/models/family/sync_complete_event.rb b/app/models/family/sync_complete_event.rb index ef10e14dc..859e380fd 100644 --- a/app/models/family/sync_complete_event.rb +++ b/app/models/family/sync_complete_event.rb @@ -6,11 +6,19 @@ class Family::SyncCompleteEvent end def broadcast - # Broadcast a refresh signal instead of rendered HTML. Each user's browser - # re-fetches via their own authenticated request, so the balance sheet and - # net worth chart are correctly scoped to the current user (Current.user is - # nil in background jobs, which would produce an unscoped family-wide view). - family.broadcast_refresh + # Append a lightweight toast to the notification tray instead of a full + # page refresh. The sync-toast Stimulus controller handles two cases: + # - User is idle → auto-reloads after a short delay + # - User is mid-form → toast stays visible; user clicks "Refresh now" + # + # This avoids wiping in-progress form state when a background sync fires. + # The partial contains no user-scoped data (Current.user is nil here), so + # each browser re-fetches the page on its own authenticated request. + family.broadcast_replace_to( + family, + target: "sync-toast", + partial: "shared/notifications/sync_toast" + ) # Schedule recurring transaction pattern identification (debounced to run after all syncs complete) begin diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index 59f8140f0..ae507fbbf 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -22,6 +22,7 @@
<%= render_flash_notifications %> +
diff --git a/app/views/shared/notifications/_sync_toast.html.erb b/app/views/shared/notifications/_sync_toast.html.erb new file mode 100644 index 000000000..763a6557f --- /dev/null +++ b/app/views/shared/notifications/_sync_toast.html.erb @@ -0,0 +1,23 @@ +
+
+
+ <%= icon "refresh-cw", size: "xs", color: "inverse" %> +
+
+ +
+

<%= t("shared.sync_toast.message") %>

+ +
+ +
+ +
+
diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 6acc96633..095846333 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -4,6 +4,9 @@ en: self_hostable: redis_configured: "Redis is now configured properly! You can now setup your Sure application." shared: + sync_toast: + message: "Your data has been updated" + refresh: "Refresh now" confirm_modal: accept: Confirm body_html: "

You will not be able to undo this decision

"