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:
parent
760e27564c
commit
eeab19ceac
|
|
@ -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 -->
|
||||||
|
|
@ -27,6 +27,32 @@ const COLORS = [
|
||||||
'#06b6d4', // cyan
|
'#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 {
|
interface UseRoomOptions {
|
||||||
slug: string;
|
slug: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
|
|
@ -59,7 +85,8 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
|
||||||
const [roomName, setRoomName] = useState(slug);
|
const [roomName, setRoomName] = useState(slug);
|
||||||
|
|
||||||
const syncRef = useRef<RoomSync | null>(null);
|
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
|
// Handle state updates from sync
|
||||||
const handleStateChange = useCallback((state: RoomState) => {
|
const handleStateChange = useCallback((state: RoomState) => {
|
||||||
|
|
@ -84,7 +111,9 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
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)];
|
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
|
||||||
|
|
||||||
// Create sync instance
|
// Create sync instance
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,9 @@ export class RoomSync {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(`rmaps_room_${this.slug}`);
|
const stored = localStorage.getItem(`rmaps_room_${this.slug}`);
|
||||||
if (stored) {
|
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) {
|
} catch (e) {
|
||||||
console.warn('Failed to load room state:', e);
|
console.warn('Failed to load room state:', e);
|
||||||
|
|
@ -128,6 +130,35 @@ export class RoomSync {
|
||||||
return null;
|
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 {
|
private saveState(): void {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(`rmaps_room_${this.slug}`, JSON.stringify(this.state));
|
localStorage.setItem(`rmaps_room_${this.slug}`, JSON.stringify(this.state));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue