From eeab19ceacbe1719ee85e48f69f990b41970c2b7 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 19 Dec 2025 13:28:35 -0500 Subject: [PATCH] fix: Clean up stale participant sessions from map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add cleanupStaleParticipants() to remove participants not seen in last hour - Use persistent participant ID per browser/room to prevent ghost duplicates - Fixes issue where old versions of yourself appeared on map reload 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ...ale-participant-sessions-showing-on-map.md | 49 +++++++++++++++++++ src/hooks/useRoom.ts | 33 ++++++++++++- src/lib/sync.ts | 33 ++++++++++++- 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 backlog/tasks/task-12 - Fix-stale-participant-sessions-showing-on-map.md diff --git a/backlog/tasks/task-12 - Fix-stale-participant-sessions-showing-on-map.md b/backlog/tasks/task-12 - Fix-stale-participant-sessions-showing-on-map.md new file mode 100644 index 0000000..07bb1cc --- /dev/null +++ b/backlog/tasks/task-12 - Fix-stale-participant-sessions-showing-on-map.md @@ -0,0 +1,49 @@ +--- +id: task-12 +title: Fix stale participant sessions showing on map +status: Done +assignee: [] +created_date: '2025-12-19 18:00' +updated_date: '2025-12-19 18:20' +labels: + - bug + - ux +dependencies: [] +priority: high +--- + +## Description + + +When opening rMaps, old participant sessions from previous visits are still showing on the map as "ghost" locations. This happens because: +1. Each session generates a new participantId via nanoid() +2. Old participants are persisted in localStorage +3. No cleanup of stale sessions occurs on reload + +Fix: Clean up stale participants on room load and use a persistent participant ID per browser. + + +## Acceptance Criteria + +- [x] #1 Old participant sessions are cleaned up on room load +- [x] #2 Same user keeps consistent participant ID across sessions +- [x] #3 No ghost duplicates appear on the map +- [x] #4 Build compiles successfully + + +## Implementation Notes + + +## Implementation + +1. **Added stale participant cleanup** (`src/lib/sync.ts:133-160`) + - `cleanupStaleParticipants()` removes participants not seen in last hour + - Runs automatically when loading room state from localStorage + - Logs how many stale participants were removed + +2. **Persistent participant ID** (`src/hooks/useRoom.ts:30-54`) + - `getOrCreateParticipantId()` stores a consistent ID per room in localStorage + - Same user returning to a room gets the same participant ID + - Prevents creating duplicate ghost entries on each page reload + - Falls back to temporary ID if localStorage unavailable + diff --git a/src/hooks/useRoom.ts b/src/hooks/useRoom.ts index 46d657b..ed6a3fb 100644 --- a/src/hooks/useRoom.ts +++ b/src/hooks/useRoom.ts @@ -27,6 +27,32 @@ const COLORS = [ '#06b6d4', // cyan ]; +/** + * Get or create a persistent participant ID for this browser/room combination. + * This prevents creating duplicate "ghost" participants on page reload. + */ +function getOrCreateParticipantId(slug: string): string { + const storageKey = `rmaps_participant_id_${slug}`; + + try { + let participantId = localStorage.getItem(storageKey); + + if (!participantId) { + participantId = nanoid(); + localStorage.setItem(storageKey, participantId); + console.log('Created new participant ID:', participantId); + } else { + console.log('Using existing participant ID:', participantId); + } + + return participantId; + } catch { + // localStorage not available (SSR or private browsing) + console.warn('localStorage not available, generating temporary participant ID'); + return nanoid(); + } +} + interface UseRoomOptions { slug: string; userName: string; @@ -59,7 +85,8 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR const [roomName, setRoomName] = useState(slug); const syncRef = useRef(null); - const participantIdRef = useRef(nanoid()); + // Initialize with empty string - will be set properly on client side + const participantIdRef = useRef(''); // Handle state updates from sync const handleStateChange = useCallback((state: RoomState) => { @@ -84,7 +111,9 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR setIsLoading(true); setError(null); - const participantId = participantIdRef.current; + // Use persistent participant ID to avoid creating duplicate "ghost" participants + const participantId = getOrCreateParticipantId(slug); + participantIdRef.current = participantId; const color = COLORS[Math.floor(Math.random() * COLORS.length)]; // Create sync instance diff --git a/src/lib/sync.ts b/src/lib/sync.ts index 58ca910..0ce7fd9 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -120,7 +120,9 @@ export class RoomSync { try { const stored = localStorage.getItem(`rmaps_room_${this.slug}`); if (stored) { - return JSON.parse(stored); + const state = JSON.parse(stored) as RoomState; + // Clean up stale participants (not seen in last hour) + return this.cleanupStaleParticipants(state); } } catch (e) { console.warn('Failed to load room state:', e); @@ -128,6 +130,35 @@ export class RoomSync { return null; } + private cleanupStaleParticipants(state: RoomState): RoomState { + const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour + const now = Date.now(); + const cleanedParticipants: Record = {}; + let removedCount = 0; + + for (const [id, participant] of Object.entries(state.participants)) { + const lastSeen = new Date(participant.lastSeen).getTime(); + const isStale = now - lastSeen > STALE_THRESHOLD_MS; + const isCurrentUser = id === this.participantId; + + // Keep current user (they'll be updated) and non-stale participants + if (isCurrentUser || !isStale) { + cleanedParticipants[id] = participant; + } else { + removedCount++; + } + } + + if (removedCount > 0) { + console.log(`Cleaned up ${removedCount} stale participant(s) from room state`); + } + + return { + ...state, + participants: cleanedParticipants, + }; + } + private saveState(): void { try { localStorage.setItem(`rmaps_room_${this.slug}`, JSON.stringify(this.state));