fix: Clean up stale participant sessions from map

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-19 13:28:35 -05:00
parent 760e27564c
commit eeab19ceac
3 changed files with 112 additions and 3 deletions

View File

@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
## 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
<!-- SECTION:NOTES:END -->

View File

@ -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<RoomSync | null>(null);
const participantIdRef = useRef<string>(nanoid());
// Initialize with empty string - will be set properly on client side
const participantIdRef = useRef<string>('');
// 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

View File

@ -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<string, ParticipantState> = {};
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));