#594 meetings alert: replay chime after postpone

The user reported: when a 5-min meeting reminder fires and they hit
"Postpone 5 min", the second reminder appears at the new time but
**without sound** — and the only way to get the sound back is to leave
the app and come back (full tab reload).

Root cause: `UpcomingMeetingAlert` keeps a `playedRef: Set<number>` of
meeting ids that have already chimed, so the 30s polling/refetch
doesn't replay the chime over and over. The alert is mounted globally
in `App.tsx` and never unmounts during in-app navigation, so the Set
lives for the whole session. Postpone changes the meeting's
`startTime` but keeps the same `id`, so the second alert hit the
"already chimed" guard and stayed silent. Only a real tab reload
cleared the Set, which is exactly the workaround the user discovered.

Fix: change the dedupe key from `meeting.id` (number) to
`${meeting.id}:${startTime}` (string). Every postpone moves
startTime, so each post-postpone alert is treated as a fresh
instance and chimes again. The 3s throttle inside
`NotificationPlayer` still protects against burst-replay within the
same alert instance.

Untouched:
- `shownRef` — still keyed by id, because the server-side "shown"
  audit row is one-per-meeting-per-user by design.
- `NotificationPlayer`, iOS unlock path, vibration, throttle.
- Postpone API surface and `PostponeDialog`.

Verified: `pnpm --filter @workspace/tx-os exec tsc --noEmit` clean.
This commit is contained in:
riyadhafraa
2026-05-18 11:26:15 +00:00
parent 1d1372257a
commit ccb891d4ca
@@ -455,21 +455,32 @@ export function UpcomingMeetingAlert() {
}, [eligibleId]);
// Play the meeting reminder sound exactly once per *new* eligible
// meeting (deduped by id) so polling refetches don't replay the chime
// every 30s. Plays whether the tab is foreground or background — the
// user wants to hear the reminder either way. Honors mute and the
// alert-instance so polling refetches don't replay the chime every
// 30s. Plays whether the tab is foreground or background — the user
// wants to hear the reminder either way. Honors mute and the
// per-channel meeting toggle.
const playedRef = useRef<Set<number>>(new Set());
//
// #594: the dedupe key intentionally includes `startTime`, not just
// `meeting.id`. When the user postpones a meeting by N minutes, the
// server keeps the same `id` but moves `startTime` forward. Without
// the start-time in the key, the second alert (at the new time) was
// treated as "already chimed" and stayed silent — only a full tab
// reload would clear `playedRef` and restore the chime. Keying on
// `id:startTime` makes every postpone (and any future reschedule)
// count as a fresh alert instance and chime again.
const playedRef = useRef<Set<string>>(new Set());
const eligibleStartTime = eligible?.meeting.startTime ?? null;
useEffect(() => {
if (!eligibleId || !user) return;
if (playedRef.current.has(eligibleId)) return;
playedRef.current.add(eligibleId);
if (!eligibleId || !eligibleStartTime || !user) return;
const key = `${eligibleId}:${eligibleStartTime}`;
if (playedRef.current.has(key)) return;
playedRef.current.add(key);
if (user.notificationsMuted) return;
if (!user.notifyMeetingsEnabled) return;
notificationPlayer.play(user.notificationSoundMeeting, {
vibrate: user.vibrationEnabledMeeting,
});
}, [eligibleId, user]);
}, [eligibleId, eligibleStartTime, user]);
if (!enabled || !eligible) return null;
// User toggled the popup off in settings — render nothing. Detection