12 KiB

GPS Tracking System

Overview

The GPS tracking system provides real-time volunteer location monitoring with breadcrumb trail recording, distance calculation, and route visualization. It integrates with canvassing sessions for field organizing oversight and volunteer safety.

Key Capabilities:

  • Live Tracking: Real-time volunteer GPS positions
  • Breadcrumb Trails: Auto-record GPS points every 10 seconds
  • Distance Calculation: Haversine formula for accurate walking distance
  • Event Markers: Mark key events (session start, visits, session end)
  • Route Visualization: Leaflet polyline with color-coded event markers
  • 1:1 Canvass Link: Each TrackingSession linked to one CanvassSession
  • Admin Oversight: View live volunteer positions on map
  • Privacy Controls: Tracking only during active canvass sessions

Architecture

graph TD
    A[Volunteer GPS] -->|watchPosition| B[GPSTracker Component]
    B -->|Buffer Points| C[Local Storage]
    C -->|Submit Every 10s| D[POST /api/map/tracking/sessions/:id/points]
    D -->|Batch Insert| E[Tracking Service]
    E -->|Save Points| F[(TrackPoint Model)]
    E -->|Calculate Distance| G[Haversine Formula]
    G -->|Update Session| H[(TrackingSession Model)]

    I[Canvass Session] -->|Start| J[Canvass Service]
    J -->|Create 1:1| E
    E -->|Create| H

    K[Admin] -->|View Live Map| L[CanvassDashboardPage]
    L -->|GET /api/map/tracking/admin/live| E
    E -->|Query Active| H
    E -->|Return Positions| L

    M[Volunteer] -->|View Route History| N[MyRoutesPage]
    N -->|GET /api/map/tracking/sessions/:id/route| E
    E -->|Query Points| F
    E -->|Generate Polyline| N

    H -->|1:1| I
    H -->|1:N| F

    style H fill:#e1f5ff
    style F fill:#e1f5ff

Flow Description:

  1. Canvass session starts → Create TrackingSession linked 1:1
  2. GPS auto-tracking → watchPosition submits points every 10s
  3. Distance calculation → Haversine formula calculates incremental distance
  4. Event markers → Mark visits, session start/end with eventType
  5. Admin oversight → View live volunteer positions on dashboard
  6. Route history → Generate polyline from saved TrackPoints

Database Models

TrackingSession Model

See TrackingSession Model Documentation.

Key Fields:

  • userId: Foreign key to volunteer User
  • canvassSessionId: 1:1 foreign key to CanvassSession
  • startedAt: Tracking start timestamp
  • endedAt: Tracking end timestamp (null while active)
  • isActive: Boolean - tracking currently running
  • totalPoints: Count of TrackPoint records
  • totalDistanceM: Total distance walked in meters
  • lastLatitude / lastLongitude: Most recent GPS position
  • lastRecordedAt: Timestamp of last GPS point

TrackPoint Model

See TrackPoint Model Documentation.

Key Fields:

  • trackingSessionId: Foreign key to TrackingSession
  • latitude / longitude: GPS coordinates (Decimal type)
  • accuracy: GPS accuracy in meters (lower = better)
  • recordedAt: When point was recorded (client timestamp)
  • eventType: Optional event marker (LOCATION_ADDED, VISIT_RECORDED, SESSION_STARTED, SESSION_ENDED)

Event Type Enum:

enum TrackPointEventType {
  LOCATION_ADDED   // Regular GPS breadcrumb
  VISIT_RECORDED   // Canvass visit recorded
  SESSION_STARTED  // Canvass session started
  SESSION_ENDED    // Canvass session ended
}

API Endpoints

See Tracking Backend Module Documentation.

Volunteer Endpoints:

Method Endpoint Auth Description
POST /api/map/tracking/sessions Any logged-in user Start tracking session
PATCH /api/map/tracking/sessions/:id/end Any logged-in user End tracking session
POST /api/map/tracking/sessions/:id/points Any logged-in user Submit batch of GPS points
GET /api/map/tracking/sessions/:id Any logged-in user Get tracking session details
GET /api/map/tracking/sessions/:id/route Any logged-in user Get route polyline (all points)

Admin Endpoints:

Method Endpoint Auth Description
GET /api/map/tracking/admin/live MAP_ADMIN Get live volunteer positions
GET /api/map/tracking/admin/sessions/:id MAP_ADMIN Get volunteer tracking session
GET /api/map/tracking/admin/sessions/:id/route MAP_ADMIN Get volunteer route

Configuration

GPS Tracking Settings

Setting Default Description
SUBMIT_INTERVAL_MS 10000 Submit GPS points every 10 seconds
MAX_DISTANCE_JUMP_M 1000 Ignore GPS glitches >1km distance
HIGH_ACCURACY true Use GPS + WiFi + cellular (vs WiFi only)
MAX_AGE_MS 0 Don't use cached GPS position
TIMEOUT_MS 10000 GPS position timeout (10s)

Privacy & Security

  • Opt-In Only: Tracking only enabled when volunteer starts canvass session
  • Session-Based: Tracking ends when session ends (not continuous)
  • Admin-Only: Only MAP_ADMIN can view live positions
  • Data Retention: TrackPoints retained for analytics (consider GDPR compliance for EU campaigns)

Code Examples

Start Tracking Session (Backend)

// api/src/modules/map/tracking/tracking.service.ts
async startSession(userId: string, data: StartTrackingInput) {
  const { canvassSessionId, latitude, longitude } = data;

  // Check for existing active session
  const existing = await prisma.trackingSession.findFirst({
    where: { userId, isActive: true },
  });

  if (existing) return existing; // Reuse existing session

  return prisma.trackingSession.create({
    data: {
      userId,
      canvassSessionId: canvassSessionId ?? null,
      lastLatitude: latitude != null ? new Prisma.Decimal(latitude) : null,
      lastLongitude: longitude != null ? new Prisma.Decimal(longitude) : null,
      lastRecordedAt: latitude != null ? new Date() : null,
    },
  });
}

Submit GPS Points (Backend)

// api/src/modules/map/tracking/tracking.service.ts
const MAX_DISTANCE_JUMP_M = 1000;

async submitPoints(sessionId: string, userId: string, data: SubmitPointsInput) {
  const session = await prisma.trackingSession.findFirst({
    where: { id: sessionId, userId, isActive: true },
  });

  if (!session) {
    throw new AppError(404, 'Active tracking session not found', 'SESSION_NOT_FOUND');
  }

  const { points } = data;

  // Batch insert all points
  await prisma.trackPoint.createMany({
    data: points.map((p) => ({
      trackingSessionId: sessionId,
      latitude: new Prisma.Decimal(p.latitude),
      longitude: new Prisma.Decimal(p.longitude),
      accuracy: p.accuracy ?? null,
      recordedAt: new Date(p.recordedAt),
      eventType: p.eventType ?? null,
    })),
  });

  // Calculate incremental distance
  let addedDistance = 0;
  let prevLat = session.lastLatitude ? Number(session.lastLatitude) : null;
  let prevLng = session.lastLongitude ? Number(session.lastLongitude) : null;

  const sorted = [...points].sort(
    (a, b) => new Date(a.recordedAt).getTime() - new Date(b.recordedAt).getTime()
  );

  for (const p of sorted) {
    if (prevLat != null && prevLng != null) {
      const d = haversineDistance(prevLat, prevLng, p.latitude, p.longitude);
      if (d <= MAX_DISTANCE_JUMP_M) {
        addedDistance += d;
      }
    }
    prevLat = p.latitude;
    prevLng = p.longitude;
  }

  const lastPoint = sorted[sorted.length - 1]!;

  // Update session summary
  await prisma.trackingSession.update({
    where: { id: sessionId },
    data: {
      totalPoints: { increment: points.length },
      totalDistanceM: { increment: addedDistance },
      lastLatitude: new Prisma.Decimal(lastPoint.latitude),
      lastLongitude: new Prisma.Decimal(lastPoint.longitude),
      lastRecordedAt: new Date(lastPoint.recordedAt),
    },
  });

  return { accepted: points.length, distance: addedDistance };
}

GPS Auto-Tracking (Frontend)

// admin/src/components/canvass/GPSTracker.tsx
useEffect(() => {
  if (!trackingSessionId || !enabled) return;

  const pointsBuffer: TrackPoint[] = [];

  const watchId = navigator.geolocation.watchPosition(
    (position) => {
      const point = {
        latitude: position.coords.latitude,
        longitude: position.coords.longitude,
        accuracy: position.coords.accuracy,
        recordedAt: new Date().toISOString(),
      };

      pointsBuffer.push(point);
      setCurrentPosition([point.latitude, point.longitude]);
    },
    (error) => {
      console.error('GPS error:', error);
      message.error('GPS tracking failed');
    },
    {
      enableHighAccuracy: true,
      maximumAge: 0,
      timeout: 10000,
    }
  );

  // Submit buffered points every 10 seconds
  const interval = setInterval(async () => {
    if (pointsBuffer.length === 0) return;

    try {
      await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {
        points: pointsBuffer.splice(0), // Drain buffer
      });
    } catch (error) {
      console.error('Failed to submit GPS points:', error);
    }
  }, 10000);

  return () => {
    navigator.geolocation.clearWatch(watchId);
    clearInterval(interval);
  };
}, [trackingSessionId, enabled]);

Route Visualization (Frontend)

// admin/src/pages/volunteer/MyRoutesPage.tsx
const fetchRoute = async (sessionId: string) => {
  const { data } = await api.get(`/map/tracking/sessions/${sessionId}/route`);

  // Convert TrackPoints to polyline coordinates
  const polyline = data.points.map((p: TrackPoint) => [p.latitude, p.longitude]);

  // Extract event markers
  const events = data.points
    .filter((p: TrackPoint) => p.eventType)
    .map((p: TrackPoint) => ({
      position: [p.latitude, p.longitude],
      eventType: p.eventType,
      recordedAt: p.recordedAt,
    }));

  setRoute({ polyline, events, distance: data.totalDistanceM });
};

// Render route
<Polyline positions={route.polyline} pathOptions={{ color: '#3498db', weight: 3 }} />
{route.events.map((event, i) => (
  <Marker
    key={i}
    position={event.position}
    icon={getEventIcon(event.eventType)}
  >
    <Popup>{event.eventType} - {dayjs(event.recordedAt).format('HH:mm')}</Popup>
  </Marker>
))}

Troubleshooting

Issue: GPS Tracking Draining Battery

Solutions:

  1. Reduce accuracy: enableHighAccuracy: false
  2. Increase submit interval: SUBMIT_INTERVAL_MS = 30000 (30s)
  3. Add pause/resume tracking buttons

Issue: Distance Calculation Incorrect

Symptoms: Total distance much higher than expected

Causes: GPS glitches causing large jumps

Solutions:

Increase MAX_DISTANCE_JUMP_M threshold to ignore outliers:

const MAX_DISTANCE_JUMP_M = 2000; // Was 1000, increase to 2000

Issue: Route Polyline Jagged

Symptoms: Route looks zigzag instead of smooth

Causes: GPS accuracy poor (±20m)

Solutions:

Apply smoothing algorithm to polyline:

import { simplify } from '@turf/turf';

const smoothed = simplify(polyline, { tolerance: 0.0001, highQuality: true });

Performance Considerations

Batch Point Insertion

Efficient Bulk Insert:

// Insert all points in single transaction
await prisma.trackPoint.createMany({
  data: points.map((p) => ({ ... })),
});

// Avoid N+1: single UPDATE instead of N UPDATEs
await prisma.trackingSession.update({
  where: { id: sessionId },
  data: {
    totalPoints: { increment: points.length },
    totalDistanceM: { increment: totalDistance },
  },
});

Query Optimization

Index for Route Queries:

CREATE INDEX idx_track_points_session_time ON "TrackPoint" ("trackingSessionId", "recordedAt");

Efficient Route Query:

const points = await prisma.trackPoint.findMany({
  where: { trackingSessionId: sessionId },
  orderBy: { recordedAt: 'asc' },
  select: { latitude: true, longitude: true, recordedAt: true, eventType: true },
});