fix: Improve location handling, session persistence, and mobile UI

Location fixes:
- Add coordinate validation to reject invalid (0,0) and out-of-bounds locations
- Clean up stale participants after 15 minutes (was 1 hour)
- Remove invalid locations from localStorage on load
- Use CCH venue center instead of (0,0) for indoor positions without GPS

Session persistence:
- Remember user name and emoji across sessions on home page
- Pre-fill profile form with saved user info

Mobile UI improvements:
- Add floating location share button inside the map
- Visible on all screen sizes with responsive text
- Prominent styling: white when inactive, green when sharing

🤖 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-28 23:34:00 +01:00
parent a6c124c14c
commit e59150a9ce
5 changed files with 143 additions and 11 deletions

View File

@ -1,10 +1,11 @@
---
id: task-1
title: Implement navigation routes
status: To Do
assignee: []
status: Done
assignee: [@claude]
created_date: '2025-12-15 19:37'
labels: []
completed_date: '2025-12-28'
labels: [feature, navigation]
dependencies: []
priority: high
---
@ -14,3 +15,27 @@ priority: high
<!-- SECTION:DESCRIPTION:BEGIN -->
Add routing between participants and waypoints using c3nav for indoor routes and OSRM/GraphHopper for outdoor routes
<!-- SECTION:DESCRIPTION:END -->
## Implementation Notes
### Components Added
- `/api/routing/route.ts` - API endpoint for route calculation
- `RouteOverlay.tsx` - Map overlay component for visualizing routes
- `NavigationPanel.tsx` - UI panel for selecting navigation targets
### Features Implemented
- **Outdoor routing**: Uses OSRM public API for walking/driving routes
- **Indoor routing**: Integrates c3nav API for CCC venue navigation
- **Mixed routes**: Handles transitions between indoor/outdoor
- **Route visualization**: GeoJSON line layer on MapLibre GL map
- **Navigation UI**: Click on participant/waypoint to get directions
- **State management**: Route state integrated into Zustand store
### Technical Details
- Route segments are typed as `outdoor`, `indoor`, or `transition`
- Distance/time estimates calculated from OSRM and c3nav responses
- Route line rendered with outline for visibility
- Map auto-fits to route bounds when calculated
### Commit
`a6c124c` - feat: Add navigation routes feature with indoor/outdoor routing

View File

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { nanoid } from 'nanoid';
@ -17,8 +17,32 @@ export default function HomePage() {
const [isCreating, setIsCreating] = useState(false);
const [joinSlug, setJoinSlug] = useState('');
const [name, setName] = useState('');
const [emoji, setEmoji] = useState(EMOJI_OPTIONS[Math.floor(Math.random() * EMOJI_OPTIONS.length)]);
const [emoji, setEmoji] = useState('');
const [roomName, setRoomName] = useState('');
const [isLoaded, setIsLoaded] = useState(false);
// Load saved user info from localStorage on mount
useEffect(() => {
let loadedEmoji = '';
try {
const stored = localStorage.getItem('rmaps_user');
if (stored) {
const user = JSON.parse(stored);
if (user.name) setName(user.name);
if (user.emoji) {
setEmoji(user.emoji);
loadedEmoji = user.emoji;
}
}
} catch {
// Ignore parse errors
}
// Set random emoji if none loaded
if (!loadedEmoji) {
setEmoji(EMOJI_OPTIONS[Math.floor(Math.random() * EMOJI_OPTIONS.length)]);
}
setIsLoaded(true);
}, []);
const handleCreateRoom = async () => {
if (!name.trim()) return;

View File

@ -208,6 +208,8 @@ export default function RoomPage() {
currentUserId={currentParticipantId || undefined}
currentLocation={currentLocation}
eventId="38c3"
isSharing={isSharing}
onToggleSharing={handleToggleSharing}
onParticipantClick={(p) => {
setSelectedParticipant(p);
setShowParticipants(true);

View File

@ -38,6 +38,10 @@ interface DualMapViewProps {
onParticipantClick?: (participant: Participant) => void;
onWaypointClick?: (waypoint: Waypoint) => void;
onIndoorPositionSet?: (position: { level: number; x: number; y: number }) => void;
/** Whether location sharing is active */
isSharing?: boolean;
/** Callback to toggle location sharing */
onToggleSharing?: () => void;
}
// CCC venue bounds (Hamburg Congress Center)
@ -58,6 +62,8 @@ export default function DualMapView({
onParticipantClick,
onWaypointClick,
onIndoorPositionSet,
isSharing = false,
onToggleSharing,
}: DualMapViewProps) {
const [mode, setMode] = useState<MapMode>(initialMode);
const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor');
@ -152,6 +158,42 @@ export default function DualMapView({
/>
)}
{/* Location sharing button - floating inside map for mobile visibility */}
{onToggleSharing && (
<button
onClick={onToggleSharing}
className={`absolute top-4 right-4 z-30 flex items-center gap-2 px-4 py-3 rounded-full shadow-lg transition-all ${
isSharing
? 'bg-rmaps-primary text-white'
: 'bg-white text-gray-800 hover:bg-gray-100'
}`}
title={isSharing ? 'Stop sharing location' : 'Share my location'}
>
<svg
className="w-5 h-5"
fill={isSharing ? 'currentColor' : 'none'}
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span className="text-sm font-medium hidden sm:inline">
{isSharing ? 'Sharing' : 'Share Location'}
</span>
</button>
)}
{/* Indoor Map button - switch to indoor view */}
{activeView === 'outdoor' && (
<button

View File

@ -81,6 +81,19 @@ export type SyncMessage =
type SyncCallback = (state: RoomState) => void;
type ConnectionCallback = (connected: boolean) => void;
// Validate that coordinates are reasonable (not 0,0 or out of bounds)
function isValidLocation(location: LocationState | undefined): boolean {
if (!location) return false;
const { latitude, longitude } = location;
// Basic bounds check
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return false;
// Reject (0,0) which is a common invalid default
if (latitude === 0 && longitude === 0) return false;
// Reject NaN
if (isNaN(latitude) || isNaN(longitude)) return false;
return true;
}
export class RoomSync {
private slug: string;
private state: RoomState;
@ -132,10 +145,11 @@ export class RoomSync {
}
private cleanupStaleParticipants(state: RoomState): RoomState {
const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes (was 1 hour)
const now = Date.now();
const cleanedParticipants: Record<string, ParticipantState> = {};
let removedCount = 0;
let invalidLocationCount = 0;
for (const [id, participant] of Object.entries(state.participants)) {
const lastSeen = new Date(participant.lastSeen).getTime();
@ -144,6 +158,12 @@ export class RoomSync {
// Keep current user (they'll be updated) and non-stale participants
if (isCurrentUser || !isStale) {
// Also clean up invalid locations
if (participant.location && !isValidLocation(participant.location)) {
console.log(`Removing invalid location for participant ${id}:`, participant.location);
delete participant.location;
invalidLocationCount++;
}
cleanedParticipants[id] = participant;
} else {
removedCount++;
@ -153,6 +173,9 @@ export class RoomSync {
if (removedCount > 0) {
console.log(`Cleaned up ${removedCount} stale participant(s) from room state`);
}
if (invalidLocationCount > 0) {
console.log(`Cleaned up ${invalidLocationCount} invalid location(s) from room state`);
}
return {
...state,
@ -257,8 +280,17 @@ export class RoomSync {
case 'location':
if (this.state.participants[message.participantId]) {
// Validate location before accepting
if (message.location && isValidLocation(message.location)) {
this.state.participants[message.participantId].location = message.location;
this.state.participants[message.participantId].lastSeen = new Date().toISOString();
} else if (message.location === null) {
// Allow explicit null to clear location
delete this.state.participants[message.participantId].location;
this.state.participants[message.participantId].lastSeen = new Date().toISOString();
} else {
console.warn('Ignoring invalid location from participant:', message.participantId, message.location);
}
}
break;
@ -310,6 +342,11 @@ export class RoomSync {
updateLocation(location: LocationState): void {
console.log('RoomSync.updateLocation called:', location.latitude, location.longitude);
// Validate location before setting
if (!isValidLocation(location)) {
console.warn('Rejecting invalid location:', location);
return;
}
if (this.state.participants[this.participantId]) {
this.state.participants[this.participantId].location = location;
this.state.participants[this.participantId].lastSeen = new Date().toISOString();
@ -346,11 +383,13 @@ export class RoomSync {
console.log('RoomSync.updateIndoorPosition called:', indoor.level, indoor.x, indoor.y);
if (this.state.participants[this.participantId]) {
// Update or create location with indoor data
// Use CCH venue center as default when no outdoor location exists
const existingLocation = this.state.participants[this.participantId].location;
const CCH_CENTER = { latitude: 53.5555, longitude: 9.9898 }; // Hamburg CCH venue center
const location: LocationState = existingLocation || {
latitude: 0,
longitude: 0,
accuracy: 0,
latitude: CCH_CENTER.latitude,
longitude: CCH_CENTER.longitude,
accuracy: 50, // Indoor accuracy
timestamp: new Date().toISOString(),
source: 'manual',
};