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

"