Compare commits

3 Commits

Author SHA1 Message Date
Riyadh 41792d8493 Transitioned from Plan to Build mode 2026-05-24 09:41:06 +00:00
Riyadh 3ceabb85bc 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.
2026-05-24 08:33:53 +00:00
Riyadh 1096217420 Transitioned from Plan to Build mode 2026-05-24 08:16:55 +00:00
4 changed files with 84 additions and 21 deletions
@@ -134,7 +134,17 @@ async function notifyUser(
})
.returning();
await emitToUser(userId, "notification_created", notification);
void sendPushToUser(userId, {
// #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,
@@ -145,7 +155,9 @@ async function notifyUser(
relatedId: orderId,
tag: `order-${orderId}`,
url: "/my-orders",
});
},
{ ignoreConnected: true },
);
}
async function broadcastIncomingChanged(receiverIds: number[]) {
@@ -22,11 +22,18 @@ export function useAutoplayHint(): void {
const handler = (e: Event) => {
const detail = (e as CustomEvent<AutoplayBlockedDetail>).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);
@@ -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
+31 -7
View File
@@ -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<string, number>();
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");