From 96b8e0bcb982022878e1090b75ba29a2f538ffab Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Sun, 24 May 2026 08:33:53 +0000 Subject: [PATCH] Improve notification sound throttling and delivery reliability Refactor notification sound playback to use per-bucket throttling, adjust socket warmup, and ensure push notifications are sent even if the client is considered connected. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 3dcee04b-6717-4c1f-a172-b1f9c2febbe0 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/qrzw3bH Replit-Helium-Checkpoint-Created: true --- .../api-server/src/routes/service-orders.ts | 36 ++++++++++++------ .../tx-os/src/hooks/use-autoplay-hint.ts | 7 ++++ .../src/hooks/use-notifications-socket.ts | 24 +++++++++++- .../tx-os/src/lib/notification-sounds.ts | 38 +++++++++++++++---- 4 files changed, 84 insertions(+), 21 deletions(-) diff --git a/artifacts/api-server/src/routes/service-orders.ts b/artifacts/api-server/src/routes/service-orders.ts index 8d5e87a6..67dead47 100644 --- a/artifacts/api-server/src/routes/service-orders.ts +++ b/artifacts/api-server/src/routes/service-orders.ts @@ -134,18 +134,30 @@ async function notifyUser( }) .returning(); await emitToUser(userId, "notification_created", notification); - void sendPushToUser(userId, { - title: titleAr, - body: bodyAr, - titleAr, - titleEn, - bodyAr, - bodyEn, - type: "order", - relatedId: orderId, - tag: `order-${orderId}`, - url: "/my-orders", - }); + // #628: pass `ignoreConnected: true` so a stale/ghost Socket.IO + // session on a forgotten laptop tab no longer suppresses the Web + // Push to the receiver's iPad. Orders are time-critical exactly + // like the 5-min meeting reminder — better to ring twice on the + // foreground device than to ring zero times on the device the user + // is actually carrying. The in-app chime path stays unchanged for + // truly-foregrounded sockets; this just removes the silent-skip + // when the "connected" tab is in fact idle/closed. + void sendPushToUser( + userId, + { + title: titleAr, + body: bodyAr, + titleAr, + titleEn, + bodyAr, + bodyEn, + type: "order", + relatedId: orderId, + tag: `order-${orderId}`, + url: "/my-orders", + }, + { ignoreConnected: true }, + ); } async function broadcastIncomingChanged(receiverIds: number[]) { diff --git a/artifacts/tx-os/src/hooks/use-autoplay-hint.ts b/artifacts/tx-os/src/hooks/use-autoplay-hint.ts index 4692ac1a..db48d9d2 100644 --- a/artifacts/tx-os/src/hooks/use-autoplay-hint.ts +++ b/artifacts/tx-os/src/hooks/use-autoplay-hint.ts @@ -22,11 +22,18 @@ export function useAutoplayHint(): void { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; const isIos = !!detail?.isIos; + // #628: the autoplay hint was previously an auto-dismissing + // toast that the user could easily miss while looking at the + // iPad from across the room. Make it sticky (no auto-dismiss) + // so it stays on screen until acknowledged — otherwise the + // first incoming order goes silently unnoticed and the user + // never realises notifications need a tap to unlock. toast({ title: t("notifSettings.autoplayHintTitle"), description: isIos ? t("notifSettings.autoplayHintIos") : t("notifSettings.autoplayHint"), + duration: Number.POSITIVE_INFINITY, }); }; window.addEventListener(AUTOPLAY_BLOCKED_EVENT, handler); diff --git a/artifacts/tx-os/src/hooks/use-notifications-socket.ts b/artifacts/tx-os/src/hooks/use-notifications-socket.ts index 2491340a..763dff6d 100644 --- a/artifacts/tx-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/tx-os/src/hooks/use-notifications-socket.ts @@ -26,7 +26,14 @@ import { const BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); -const SOCKET_WARMUP_MS = 3000; +// #628: warmup window after socket (re)connect during which chimes +// are suppressed so a page-load flush of pending events doesn't sound +// like an alarm. Lowered from 3000 → 1000 ms because on mobile the +// socket disconnects/reconnects often (background tab, tunnel hop, +// PWA wake-up); a 3-second window silently swallowed any order that +// arrived in the first few seconds after returning to the app. 1 s +// still covers the synchronous initial-burst at first connect. +const SOCKET_WARMUP_MS = 1000; const nowMs = () => typeof performance !== "undefined" ? performance.now() : Date.now(); @@ -127,7 +134,18 @@ export function useNotificationsSocket() { const vibrate = isOrder ? user.vibrationEnabledOrder : user.vibrationEnabledMeeting; - notificationPlayer.play(soundId, { vibrate }); + // #628: order chimes use a tighter throttle window so a small + // burst of incoming orders still rings for each one. Meeting + // chimes keep the default 3s window — a single meeting event + // can fan out to several listeners and we don't want a + // buzzy double-chime. Buckets keep the two channels isolated + // so an order burst can't starve a meeting alert (or vice + // versa). + notificationPlayer.play(soundId, { + vibrate, + throttleMs: isOrder ? 800 : 3000, + throttleBucket: isOrder ? "order" : "meeting", + }); }); socket.on("order_updated", () => { @@ -199,6 +217,7 @@ export function useNotificationsSocket() { playedNoteIdsRef.current.add(payload.noteId); notificationPlayer.play(user.notificationSoundNote, { vibrate: user.vibrationEnabledNote, + throttleBucket: "note", }); } // Only fall back to a toast when the floating popup did NOT @@ -284,6 +303,7 @@ export function useNotificationsSocket() { playedReplyIdsRef.current.add(replyId); notificationPlayer.play(user.notificationSoundNote, { vibrate: user.vibrationEnabledNote, + throttleBucket: "note", }); } // See `note_received` above: skip the redundant toast when the diff --git a/artifacts/tx-os/src/lib/notification-sounds.ts b/artifacts/tx-os/src/lib/notification-sounds.ts index e16eb422..0a044258 100644 --- a/artifacts/tx-os/src/lib/notification-sounds.ts +++ b/artifacts/tx-os/src/lib/notification-sounds.ts @@ -118,7 +118,13 @@ class NotificationPlayer { // ---- Shared state ---- private unlocked = false; - private lastPlayAt = 0; + // #628: per-bucket throttle timestamps. Was a single global value, + // which coupled channels: lowering the order throttle to 800ms + // caused rapid order chimes to keep refreshing the global timestamp + // and starve meeting/note chimes (whose 3s window kept restarting). + // Bucketing isolates each channel so order bursts can't mute + // meeting/note alerts and vice versa. + private lastPlayAtByBucket = new Map(); private hintDispatched = false; private get useIos(): boolean { @@ -334,16 +340,34 @@ class NotificationPlayer { */ play( soundId: NotificationSoundId, - opts: { vibrate?: boolean; bypassThrottle?: boolean } = {}, + opts: { + vibrate?: boolean; + bypassThrottle?: boolean; + // #628: per-call throttle override. Order chimes drop to 800ms + // so a small burst of incoming orders (rare but real during + // service rushes) still rings for each one instead of swallowing + // every order after the first for the next 3 seconds. Other + // channels keep the original 3000ms coalescing window so a + // single meeting fanout that touches several listeners doesn't + // produce a buzzy double-chime. + throttleMs?: number; + // #628: throttle bucket key. Defaults to "default" so legacy + // callers behave as before. Callers that want isolated coalescing + // (e.g. "order" vs "meeting" vs "note") pass distinct buckets so + // a burst on one channel can't extend the silence window of + // another channel. + throttleBucket?: string; + } = {}, ): void { - // Throttle: at most one sound every 3s to coalesce bursts of - // notifications into a single chime. Settings "Test sound" - // bypasses this so users can audition multiple sounds quickly. const now = typeof performance !== "undefined" ? performance.now() : Date.now(); if (!opts.bypassThrottle) { - if (now - this.lastPlayAt < 3000) return; - this.lastPlayAt = now; + const window = + typeof opts.throttleMs === "number" ? opts.throttleMs : 3000; + const bucket = opts.throttleBucket ?? "default"; + const last = this.lastPlayAtByBucket.get(bucket) ?? 0; + if (now - last < window) return; + this.lastPlayAtByBucket.set(bucket, now); } const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");