25 KiB

Volunteer Map Page

Overview

File Path: admin/src/pages/volunteer/VolunteerMapPage.tsx (570 lines)

Route: /volunteer/canvass/:cutId

Role Requirements: Authenticated users (USER or TEMP role)

Purpose: Full-screen GPS-enabled canvassing map for door-to-door volunteer work with visit recording, route navigation, location management, and session tracking.

Key Features:

  • Full-screen map (no AppLayout wrapper)
  • Real-time GPS tracking with following mode
  • Canvass session management (start/end with auto-pause)
  • Walking route display with toggle
  • CanvassMarkerGroup for clustered address display
  • VisitRecordingForm in bottom drawer
  • AddLocationDrawer (crosshair + tap to add)
  • VolunteerMapDrawer (menu, stats, session picker)
  • VolunteerFooterNav (bottom navigation bar)
  • VolunteerSessionBar (active session indicator above footer)
  • TileLayerToggle (OpenStreetMap, CARTO, Satellite)
  • AddressSearchOverlay
  • Next door button (finds nearest unvisited location)
  • Cut polygon overlays with toggle controls
  • Admin edit mode (LocationEditDrawer for MAP_ADMIN users)
  • Tracking integration (links to GPS tracking sessions)

Layout: No layout wrapper - full viewport with custom overlays


Features

1. GPS Tracking

Real-time position tracking with following mode:

Components:

  • GPSTracker component (useEffect with watchPosition)
  • Blue pulsing circle marker at user's position
  • Accuracy circle (outer ring)
  • GPS path polyline (breadcrumb trail)
  • Follow mode toggle (auto-pan to user)

Code:

<GPSTracker
  enabled={sessionActive}
  onPositionUpdate={(position) => {
    setUserPosition(position);
    if (followMode) {
      map.panTo(position.coords);
    }
    // Track position for session
    trackPosition(position);
  }}
  onError={(error) => {
    message.error('GPS unavailable: ' + error.message);
  }}
/>

2. Session Management

Canvass session lifecycle:

States:

  • ACTIVE: Session in progress
  • PAUSED: Session paused (GPS stopped)
  • ENDED: Session completed

Controls:

  • Start Session button (green)
  • Pause Session button (yellow)
  • End Session button (red, with confirmation)
  • Auto-pause after 30min inactivity

Session Bar:

<VolunteerSessionBar
  session={activeSession}
  onPause={handlePauseSession}
  onEnd={() => setEndModalVisible(true)}
  style={{
    position: 'fixed',
    bottom: 60,
    left: 0,
    right: 0,
    zIndex: 1000
  }}
/>

3. Walking Route Display

Optimized door-to-door route:

Algorithm:

  • Nearest neighbor with deduplication
  • Starts from user's current position
  • Visits unvisited locations in order
  • Avoids backtracking

Visual:

  • Blue polyline connecting locations
  • Dashed line style
  • Toggle button to show/hide
  • Route recalculates when locations visited

Code:

{routeVisible && walkingRoute && (
  <WalkingRouteLine
    route={walkingRoute}
    userPosition={userPosition}
  />
)}

4. Canvass Markers

Location markers with clustering:

CanvassMarkerGroup Component:

  • Clusters nearby markers (radius: 50px)
  • Color-coded by support level
  • Click to open VisitRecordingForm
  • Shows last visit outcome if visited
  • Purple markers for multi-unit buildings

Marker States:

  • Unvisited: Gray circle
  • Visited: Color-coded by outcome
  • Selected: Larger radius + pulsing animation

5. Visit Recording Form

Bottom drawer for recording visits:

Fields:

  • Address (read-only, pre-filled)
  • Outcome (dropdown: 7 options)
  • Notes (TextArea, optional)
  • Contact Interest (checkbox)
  • Follow-up Required (checkbox)

Outcome Options:

  1. Strong Support
  2. Leaning Support
  3. Undecided
  4. Leaning Opposed
  5. Opposed
  6. No Answer
  7. Not Home

Submission:

  • Creates CanvassVisit record
  • Updates location supportLevel
  • Closes drawer
  • Marker updates color
  • Next door button finds new nearest

Code:

<VisitRecordingForm
  location={selectedLocation}
  sessionId={activeSession?.id}
  visible={recordingDrawerVisible}
  onClose={() => setRecordingDrawerVisible(false)}
  onSubmit={handleVisitSubmit}
/>

6. Add Location Mode

Crosshair interface for adding missing addresses:

Activation:

  • "Add Location" button in menu
  • Opens AddLocationDrawer
  • Crosshair appears at map center
  • User pans map to position crosshair
  • "Tap Here to Add" button

AddLocationDrawer:

  • Address input (with geocoding suggestion)
  • Unit number (for multi-unit buildings)
  • Notes
  • Cancel / Confirm buttons

Code:

<AddLocationDrawer
  visible={addLocationMode}
  position={map.getCenter()}
  onConfirm={handleAddLocation}
  onCancel={() => setAddLocationMode(false)}
/>

7. Map Controls

Floating control panels:

VolunteerMapDrawer (left side):

  • Menu button (hamburger)
  • Session stats (visits today, doors knocked)
  • Session picker dropdown
  • Tile layer toggle
  • Cut overlays toggle
  • Address search
  • Add location button
  • End session button

Control Buttons (right side):

  • Geolocate (find my location)
  • Toggle walking route
  • Next door (find nearest unvisited)
  • Fullscreen toggle

8. Tile Layer Toggle

Three basemap options:

  1. OpenStreetMap: Default, detailed streets
  2. CARTO Dark: High contrast, good for day/night
  3. Satellite: Aerial imagery from Esri

Component:

<TileLayerToggle
  activeLayer={activeLayer}
  onChange={setActiveLayer}
  position="topright"
/>

9. Address Search Overlay

Quick location lookup:

Features:

  • Input with search icon
  • Autocomplete from locations in cut
  • Fly to location on select
  • Opens visit recording form

Code:

<AddressSearchOverlay
  locations={locations}
  onSelect={(location) => {
    map.flyTo([location.latitude, location.longitude], 18);
    setSelectedLocation(location);
    setRecordingDrawerVisible(true);
  }}
/>

10. Next Door Button

Intelligent location finder:

Algorithm:

  1. Filter unvisited locations in cut
  2. Calculate distance from user position
  3. Sort by distance (haversine)
  4. Select nearest
  5. Pan map and open recording form

Code:

const handleNextDoor = () => {
  const unvisited = locations.filter(loc => 
    !visits.some(v => v.locationId === loc.id)
  );
  
  if (unvisited.length === 0) {
    message.info('All locations visited!');
    return;
  }
  
  const nearest = unvisited.reduce((prev, curr) => {
    const prevDist = haversineDistance(userPosition, prev);
    const currDist = haversineDistance(userPosition, curr);
    return currDist < prevDist ? curr : prev;
  });
  
  map.flyTo([nearest.latitude, nearest.longitude], 18);
  setSelectedLocation(nearest);
  setRecordingDrawerVisible(true);
};

11. Admin Edit Mode

MAP_ADMIN users can edit locations:

Features:

  • Edit button on location popup
  • LocationEditDrawer with full form
  • Update address, support level, notes
  • Delete location (with confirmation)
  • Move location (drag marker)

Conditional Render:

{user?.role === 'MAP_ADMIN' && (
  <Button onClick={() => setEditMode(true)}>
    Edit Location
  </Button>
)}

12. Cut Overlay Toggle

Show/hide cut boundaries:

Component:

<CutOverlayControls
  cuts={cuts}
  visibleCuts={visibleCuts}
  onToggle={(cutId) => {
    setVisibleCuts(prev => {
      const next = new Set(prev);
      if (next.has(cutId)) {
        next.delete(cutId);
      } else {
        next.add(cutId);
      }
      return next;
    });
  }}
  position="bottomleft"
/>

User Workflow

Starting a Canvass Session

  1. Volunteer navigates to /volunteer/canvass/:cutId
  2. Map loads centered on cut bounds
  3. Locations load within cut
  4. Volunteer clicks "Start Session" in drawer
  5. GPS tracking activates
  6. Session bar appears at bottom (above footer)
  7. Walking route calculates and displays
  8. User position marker appears and updates

Recording a Visit

  1. Volunteer walks to address
  2. Clicks marker or uses "Next Door" button
  3. VisitRecordingForm opens in bottom drawer
  4. Volunteer selects outcome from dropdown
  5. Volunteer adds notes (optional)
  6. Volunteer checks "Follow-up Required" if needed
  7. Volunteer clicks "Save Visit"
  8. API creates CanvassVisit record
  9. Marker updates to color-coded outcome
  10. Drawer closes
  11. Walking route recalculates

Adding a Missing Location

  1. Volunteer encounters unlisted address
  2. Opens menu drawer
  3. Clicks "Add Location"
  4. Crosshair appears at map center
  5. Volunteer pans map to position crosshair over address
  6. Clicks "Tap Here to Add"
  7. AddLocationDrawer opens
  8. Volunteer enters address
  9. Volunteer clicks "Confirm"
  10. API creates Location record
  11. New marker appears on map
  12. Volunteer can immediately record visit

Finding Next Door

  1. Volunteer finishes current visit
  2. Clicks "Next Door" button (right side)
  3. Algorithm finds nearest unvisited location
  4. Map animates (flyTo) to location
  5. VisitRecordingForm opens automatically
  6. Volunteer records visit
  7. Repeats process

Ending Session

  1. Volunteer clicks "End Session" in drawer
  2. Confirmation modal appears
  3. Modal shows session stats (duration, visits, distance)
  4. Volunteer clicks "End Session" confirm button
  5. GPS tracking stops
  6. Session marked as ENDED in database
  7. Session bar disappears
  8. Volunteer can start new session or navigate away

Component Structure

import React, { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
import { Button, message, Modal } from 'antd';
import {
  AimOutlined,
  PlusOutlined,
  ArrowRightOutlined,
  FullscreenOutlined
} from '@ant-design/icons';
import GPSTracker from '../../components/canvass/GPSTracker';
import CanvassMarkerGroup from '../../components/canvass/CanvassMarkerGroup';
import WalkingRouteLine from '../../components/canvass/WalkingRouteLine';
import VisitRecordingForm from '../../components/canvass/VisitRecordingForm';
import AddLocationDrawer from '../../components/canvass/AddLocationDrawer';
import VolunteerMapDrawer from '../../components/canvass/VolunteerMapDrawer';
import VolunteerFooterNav from '../../components/canvass/VolunteerFooterNav';
import VolunteerSessionBar from '../../components/canvass/VolunteerSessionBar';
import { useCanvassStore } from '../../stores/canvass.store';
import { api } from '../../lib/api';
import 'leaflet/dist/leaflet.css';

const VolunteerMapPage: React.FC = () => {
  const { cutId } = useParams<{ cutId: string }>();
  const [map, setMap] = useState<L.Map | null>(null);
  
  // Canvass store
  const {
    activeSession,
    locations,
    visits,
    walkingRoute,
    userPosition,
    setActiveSession,
    addVisit,
    updateLocation,
    setUserPosition
  } = useCanvassStore();
  
  // UI state
  const [recordingDrawerVisible, setRecordingDrawerVisible] = useState(false);
  const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);
  const [addLocationMode, setAddLocationMode] = useState(false);
  const [drawerVisible, setDrawerVisible] = useState(false);
  const [routeVisible, setRouteVisible] = useState(true);
  const [followMode, setFollowMode] = useState(true);
  
  // Fetch locations in cut
  useEffect(() => {
    const fetchLocations = async () => {
      try {
        const response = await api.get(`/api/map/canvass/locations/${cutId}`);
        // Store in Zustand
      } catch (error) {
        message.error('Failed to load locations');
      }
    };
    
    fetchLocations();
  }, [cutId]);
  
  return (
    <div style={{ height: '100vh', width: '100vw', position: 'relative' }}>
      <MapContainer
        center={[45.5017, -73.5673]}
        zoom={16}
        zoomControl={false}
        style={{ height: '100%', width: '100%' }}
        whenCreated={setMap}
      >
        <TileLayer
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
          attribution='OSM'
        />
        
        <GPSTracker
          enabled={!!activeSession}
          onPositionUpdate={handlePositionUpdate}
        />
        
        <CanvassMarkerGroup
          locations={locations}
          visits={visits}
          onMarkerClick={handleMarkerClick}
        />
        
        {routeVisible && walkingRoute && (
          <WalkingRouteLine
            route={walkingRoute}
            userPosition={userPosition}
          />
        )}
      </MapContainer>
      
      <VolunteerMapDrawer
        visible={drawerVisible}
        onClose={() => setDrawerVisible(false)}
        onStartSession={handleStartSession}
        onEndSession={() => setEndModalVisible(true)}
        onAddLocation={() => setAddLocationMode(true)}
        session={activeSession}
        stats={sessionStats}
      />
      
      <VisitRecordingForm
        location={selectedLocation}
        sessionId={activeSession?.id}
        visible={recordingDrawerVisible}
        onClose={() => setRecordingDrawerVisible(false)}
        onSubmit={handleVisitSubmit}
      />
      
      <AddLocationDrawer
        visible={addLocationMode}
        position={map?.getCenter()}
        onConfirm={handleAddLocation}
        onCancel={() => setAddLocationMode(false)}
      />
      
      {activeSession && (
        <VolunteerSessionBar
          session={activeSession}
          onPause={handlePause}
          onEnd={() => setEndModalVisible(true)}
        />
      )}
      
      <VolunteerFooterNav activeKey="canvass" />
      
      {/* Floating controls */}
      <div style={{ position: 'absolute', right: 16, top: 16, zIndex: 1000 }}>
        <Button
          icon={<AimOutlined />}
          onClick={handleGeolocate}
          size="large"
          style={{ display: 'block', marginBottom: 8 }}
        />
        <Button
          icon={<ArrowRightOutlined />}
          onClick={handleNextDoor}
          size="large"
        />
      </div>
    </div>
  );
};

export default VolunteerMapPage;

State Management

Zustand Store (canvass.store.ts)

interface CanvassState {
  // Session
  activeSession: CanvassSession | null;
  setActiveSession: (session: CanvassSession | null) => void;
  
  // Locations
  locations: CanvassLocation[];
  setLocations: (locations: CanvassLocation[]) => void;
  updateLocation: (id: string, updates: Partial<CanvassLocation>) => void;
  
  // Visits
  visits: CanvassVisit[];
  addVisit: (visit: CanvassVisit) => void;
  
  // Route
  walkingRoute: WalkingRoute | null;
  calculateRoute: () => void;
  
  // GPS
  userPosition: GPSPosition | null;
  setUserPosition: (position: GPSPosition) => void;
  gpsPath: GPSPosition[];
  addGPSPoint: (position: GPSPosition) => void;
}

export const useCanvassStore = create<CanvassState>((set, get) => ({
  activeSession: null,
  locations: [],
  visits: [],
  walkingRoute: null,
  userPosition: null,
  gpsPath: [],
  
  setActiveSession: (session) => {
    set({ activeSession: session });
    if (session) {
      get().calculateRoute();
    }
  },
  
  addVisit: (visit) => {
    set((state) => ({
      visits: [...state.visits, visit]
    }));
    get().calculateRoute(); // Recalculate after visit
  },
  
  calculateRoute: () => {
    const { locations, visits, userPosition } = get();
    
    if (!userPosition) return;
    
    const unvisited = locations.filter(loc =>
      !visits.some(v => v.locationId === loc.id)
    );
    
    const route = calculateWalkingRoute(userPosition, unvisited);
    set({ walkingRoute: route });
  }
}));

Component State

// UI state
const [recordingDrawerVisible, setRecordingDrawerVisible] = useState(false);
const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);
const [addLocationMode, setAddLocationMode] = useState(false);
const [drawerVisible, setDrawerVisible] = useState(false);
const [endModalVisible, setEndModalVisible] = useState(false);

// Map state
const [map, setMap] = useState<L.Map | null>(null);
const [routeVisible, setRouteVisible] = useState(true);
const [followMode, setFollowMode] = useState(true);
const [activeLayer, setActiveLayer] = useState<'osm' | 'carto' | 'satellite'>('osm');

API Integration

Endpoints

1. Get Locations in Cut

GET /api/map/canvass/locations/:cutId
Authorization: Bearer {token}

Response:

[
  {
    "id": "cm1loc123",
    "address": "123 Main St",
    "latitude": 45.5017,
    "longitude": -73.5673,
    "supportLevel": null,
    "lastVisitDate": null,
    "isMultiUnit": false
  }
]

2. Start Session

POST /api/map/canvass/sessions/start
Authorization: Bearer {token}
Content-Type: application/json

{
  "cutId": "cm1cut123"
}

Response:

{
  "sessionId": "cm2session456",
  "startTime": "2025-02-12T10:00:00.000Z",
  "status": "ACTIVE"
}

3. Record Visit

POST /api/map/canvass/visits
Authorization: Bearer {token}
Content-Type: application/json

{
  "sessionId": "cm2session456",
  "locationId": "cm1loc123",
  "outcome": "strong_support",
  "notes": "Very enthusiastic, requested yard sign",
  "contactInterested": true,
  "followUpRequired": false
}

Response:

{
  "visitId": "cm3visit789",
  "createdAt": "2025-02-12T10:15:00.000Z"
}

4. Track GPS Position

POST /api/map/canvass/sessions/:sessionId/track
Authorization: Bearer {token}
Content-Type: application/json

{
  "latitude": 45.5017,
  "longitude": -73.5673,
  "accuracy": 12.5,
  "timestamp": "2025-02-12T10:15:30.000Z"
}

5. Add Location

POST /api/map/locations
Authorization: Bearer {token}
Content-Type: application/json

{
  "address": "125 Main St",
  "latitude": 45.5018,
  "longitude": -73.5672,
  "cutId": "cm1cut123",
  "notes": "Added during canvass"
}

6. End Session

POST /api/map/canvass/sessions/:sessionId/end
Authorization: Bearer {token}

Response:

{
  "sessionId": "cm2session456",
  "endTime": "2025-02-12T12:00:00.000Z",
  "duration": 7200,
  "visitCount": 23,
  "distance": 2834
}

Code Examples

GPS Tracker Component

// components/canvass/GPSTracker.tsx
const GPSTracker: React.FC<{
  enabled: boolean;
  onPositionUpdate: (position: GeolocationPosition) => void;
  onError?: (error: GeolocationPositionError) => void;
}> = ({ enabled, onPositionUpdate, onError }) => {
  useEffect(() => {
    if (!enabled || !navigator.geolocation) return;
    
    const watchId = navigator.geolocation.watchPosition(
      onPositionUpdate,
      onError,
      {
        enableHighAccuracy: true,
        maximumAge: 5000,
        timeout: 10000
      }
    );
    
    return () => navigator.geolocation.clearWatch(watchId);
  }, [enabled, onPositionUpdate, onError]);
  
  return null;
};

Walking Route Calculation

// utils/walking-route.ts
export const calculateWalkingRoute = (
  start: GPSPosition,
  locations: CanvassLocation[]
): WalkingRoute => {
  const unvisited = [...locations];
  const route: CanvassLocation[] = [];
  let current = { latitude: start.latitude, longitude: start.longitude };
  
  // Nearest neighbor algorithm
  while (unvisited.length > 0) {
    let nearestIdx = 0;
    let nearestDist = Infinity;
    
    unvisited.forEach((loc, idx) => {
      const dist = haversineDistance(current, loc);
      if (dist < nearestDist) {
        nearestDist = dist;
        nearestIdx = idx;
      }
    });
    
    const nearest = unvisited.splice(nearestIdx, 1)[0];
    route.push(nearest);
    current = nearest;
  }
  
  return {
    locations: route,
    totalDistance: calculateTotalDistance(route)
  };
};

const haversineDistance = (
  a: { latitude: number; longitude: number },
  b: { latitude: number; longitude: number }
): number => {
  const R = 6371e3; // Earth radius in meters
  const φ1 = (a.latitude * Math.PI) / 180;
  const φ2 = (b.latitude * Math.PI) / 180;
  const Δφ = ((b.latitude - a.latitude) * Math.PI) / 180;
  const Δλ = ((b.longitude - a.longitude) * Math.PI) / 180;
  
  const a1 = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
          Math.cos(φ1) * Math.cos(φ2) *
          Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
  const c = 2 * Math.atan2(Math.sqrt(a1), Math.sqrt(1 - a1));
  
  return R * c; // Distance in meters
};

Canvass Marker Group

// components/canvass/CanvassMarkerGroup.tsx
const CanvassMarkerGroup: React.FC<{
  locations: CanvassLocation[];
  visits: CanvassVisit[];
  onMarkerClick: (location: CanvassLocation) => void;
}> = ({ locations, visits, onMarkerClick }) => {
  const getMarkerColor = (location: CanvassLocation) => {
    const visit = visits.find(v => v.locationId === location.id);
    
    if (!visit) return '#8c8c8c'; // Unvisited
    
    switch (visit.outcome) {
      case 'strong_support': return '#52c41a';
      case 'leaning_support': return '#95de64';
      case 'undecided': return '#fadb14';
      case 'leaning_opposed': return '#ff7a45';
      case 'opposed': return '#f5222d';
      case 'no_answer': return '#8c8c8c';
      case 'not_home': return '#d9d9d9';
      default: return '#8c8c8c';
    }
  };
  
  return (
    <>
      {locations.map(location => (
        <CircleMarker
          key={location.id}
          center={[location.latitude, location.longitude]}
          radius={10}
          pathOptions={{
            color: 'white',
            weight: 2,
            fillColor: getMarkerColor(location),
            fillOpacity: 0.8
          }}
          eventHandlers={{
            click: () => onMarkerClick(location)
          }}
        >
          <Popup>
            <Text strong>{location.address}</Text>
            {location.unitNumber && (
              <>
                <br />
                <Text>Unit: {location.unitNumber}</Text>
              </>
            )}
          </Popup>
        </CircleMarker>
      ))}
    </>
  );
};

Performance Considerations

  1. Zustand Store: Global state prevents prop drilling
  2. Debounced GPS: Position tracked every 5 seconds (not every update)
  3. Route Recalc: Only recalculates when visits added
  4. Marker Clustering: Reduces DOM nodes on dense maps
  5. Lazy Drawers: Components mount only when opened

Responsive Design

  • Mobile-First: Designed for phones (primary use case)
  • Touch Gestures: Native Leaflet touch support
  • Bottom Drawers: Accessible with thumb
  • Large Touch Targets: All buttons 44px+ minimum
  • Portrait Orientation: Optimized for vertical screens

Accessibility

  • GPS Feedback: Audible alerts for position updates (optional)
  • High Contrast: CARTO Dark mode for low light
  • Large Text: All UI text 14px minimum
  • Voice Input: Notes field supports speech-to-text (browser)

Troubleshooting

Issue: GPS Not Working

Causes:

  1. HTTPS required (geolocation API restriction)
  2. User denied permission
  3. GPS unavailable (indoors, bad signal)

Solutions:

const handleGPSError = (error: GeolocationPositionError) => {
  switch (error.code) {
    case error.PERMISSION_DENIED:
      Modal.error({
        title: 'GPS Permission Required',
        content: 'Please enable location permissions in your browser settings.'
      });
      break;
    case error.POSITION_UNAVAILABLE:
      message.warning('GPS unavailable. Try moving outdoors.');
      break;
    case error.TIMEOUT:
      message.warning('GPS timeout. Check your device settings.');
      break;
  }
};

Issue: Route Not Displaying

Causes:

  1. No unvisited locations
  2. Route calculation error
  3. Route toggle off

Solutions:

// Add debug logging
useEffect(() => {
  console.log('Route calculation:', {
    unvisited: locations.filter(l => !visits.some(v => v.locationId === l.id)).length,
    routeVisible,
    walkingRoute: walkingRoute?.locations.length
  });
}, [locations, visits, routeVisible, walkingRoute]);

// Show message if no unvisited
if (unvisitedCount === 0) {
  message.success('All locations visited! Great work!');
}

Issue: Session Not Ending

Causes:

  1. API timeout
  2. Pending GPS uploads
  3. Network disconnection

Solutions:

const handleEndSession = async () => {
  try {
    // Upload any pending GPS points first
    await uploadPendingGPSPoints();
    
    // Then end session
    await api.post(`/api/map/canvass/sessions/${activeSession.id}/end`, {}, {
      timeout: 10000
    });
    
    message.success('Session ended');
    setActiveSession(null);
    
  } catch (error: any) {
    if (error.code === 'ECONNABORTED') {
      // Force local end if server timeout
      setActiveSession(null);
      message.warning('Session ended locally (server unreachable)');
    } else {
      message.error('Failed to end session');
    }
  }
};